Merge pull request #10089 from gijswijs/onion-messaging-1

Onion message forwarding
This commit is contained in:
Olaoluwa Osuntokun 2026-03-06 11:46:38 -06:00 committed by GitHub
commit 392d4c8cb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 7239 additions and 3798 deletions

View file

@ -160,6 +160,7 @@ linters:
- github.com/gogo/protobuf
- google.golang.org/protobuf
- github.com/lightningnetwork/lnd/sqldb
- github.com/lightningnetwork/lightning-onion
replace-local: true
gosec:

View file

@ -77,10 +77,11 @@
# New Features
- Basic Support for [onion messaging forwarding](https://github.com/lightningnetwork/lnd/pull/9868)
consisting of a new message type, `OnionMessage`. This includes the message's
definition, comprising a path key and an onion blob, along with the necessary
serialization and deserialization logic for peer-to-peer communication.
- [Basic Support](https://github.com/lightningnetwork/lnd/pull/9868) for onion
[messaging forwarding](https://github.com/lightningnetwork/lnd/pull/10089).
This adds a new message type, `OnionMessage`, comprising a path key and an
onion blob. It includes the necessary serialization and deserialization logic
for peer-to-peer communication.
## Functional Enhancements

View file

@ -111,4 +111,8 @@ var defaultSetDesc = setDesc{
SetInit: {}, // I
SetNodeAnn: {}, // N
},
lnwire.OnionMessagesOptional: {
SetInit: {}, // I
SetNodeAnn: {}, // N
},
}

View file

@ -77,6 +77,10 @@ type Config struct {
// coop close.
NoRbfCoopClose bool
// NoOnionMessages unsets any bits that signal support for onion
// messaging.
NoOnionMessages bool
// CustomFeatures is a set of custom features to advertise in each
// set.
CustomFeatures map[Set][]lnwire.FeatureBit
@ -221,6 +225,10 @@ func newManager(cfg Config, desc setDesc) (*Manager, error) {
raw.Unset(lnwire.RbfCoopCloseOptionalStaging)
raw.Unset(lnwire.RbfCoopCloseOptional)
}
if cfg.NoOnionMessages {
raw.Unset(lnwire.OnionMessagesOptional)
raw.Unset(lnwire.OnionMessagesRequired)
}
for _, custom := range cfg.CustomFeatures[set] {
if custom > set.Maximum() {

11
go.mod
View file

@ -32,7 +32,8 @@ require (
github.com/kkdai/bstream v1.0.0
github.com/lightninglabs/neutrino v0.16.1
github.com/lightninglabs/neutrino/cache v1.1.2
github.com/lightningnetwork/lightning-onion v1.2.1-0.20240815225420-8b40adf04ab9
github.com/lightningnetwork/lightning-onion v1.3.0
github.com/lightningnetwork/lnd/actor v0.0.3
github.com/lightningnetwork/lnd/cert v1.2.2
github.com/lightningnetwork/lnd/clock v1.1.1
github.com/lightningnetwork/lnd/fn/v2 v2.0.9
@ -82,7 +83,7 @@ require (
github.com/containerd/continuity v0.3.0 // indirect
github.com/coreos/go-semver v0.3.0 // indirect
github.com/coreos/go-systemd/v22 v22.3.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect
github.com/decred/dcrd/lru v1.1.2 // indirect
github.com/docker/cli v28.1.1+incompatible // indirect
@ -148,8 +149,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rogpeppe/fastuuid v1.2.0 // indirect
github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sirupsen/logrus v1.9.2 // indirect
github.com/soheilhy/cmux v0.1.5 // indirect
github.com/spf13/pflag v1.0.6 // indirect
@ -202,6 +202,9 @@ require (
sigs.k8s.io/yaml v1.2.0 // indirect
)
// TODO(gijs): remove once new actor package is released.
replace github.com/lightningnetwork/lnd/actor => ./actor
// TODO(elle): remove once the gossip V2 sqldb changes have been made.
replace github.com/lightningnetwork/lnd/sqldb => ./sqldb

12
go.sum
View file

@ -111,8 +111,8 @@ github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7
github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
@ -368,8 +368,8 @@ github.com/lightninglabs/neutrino/cache v1.1.2 h1:C9DY/DAPaPxbFC+xNNEI/z1SJY9GS3
github.com/lightninglabs/neutrino/cache v1.1.2/go.mod h1:XJNcgdOw1LQnanGjw8Vj44CvguYA25IMKjWFZczwZuo=
github.com/lightninglabs/protobuf-go-hex-display v1.33.0-hex-display h1:Y2WiPkBS/00EiEg0qp0FhehxnQfk3vv8U6Xt3nN+rTY=
github.com/lightninglabs/protobuf-go-hex-display v1.33.0-hex-display/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
github.com/lightningnetwork/lightning-onion v1.2.1-0.20240815225420-8b40adf04ab9 h1:6D3LrdagJweLLdFm1JNodZsBk6iU4TTsBBFLQ4yiXfI=
github.com/lightningnetwork/lightning-onion v1.2.1-0.20240815225420-8b40adf04ab9/go.mod h1:EDqJ3MuZIbMq0QI1czTIKDJ/GS8S14RXPwapHw8cw6w=
github.com/lightningnetwork/lightning-onion v1.3.0 h1:FqILgHjD6euc/Muo1VOzZ4+XDPuFnw6EYROBq0rR/5c=
github.com/lightningnetwork/lightning-onion v1.3.0/go.mod h1:nP85zMHG7c0si/eHBbSQpuDCtnIXfSvFrK3tW6YWzmU=
github.com/lightningnetwork/lnd/cert v1.2.2 h1:71YK6hogeJtxSxw2teq3eGeuy4rHGKcFf0d0Uy4qBjI=
github.com/lightningnetwork/lnd/cert v1.2.2/go.mod h1:jQmFn/Ez4zhDgq2hnYSw8r35bqGVxViXhX6Cd7HXM6U=
github.com/lightningnetwork/lnd/clock v1.1.1 h1:OfR3/zcJd2RhH0RU+zX/77c0ZiOnIMsDIBjgjWdZgA0=
@ -485,13 +485,13 @@ github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWN
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=

View file

@ -39,21 +39,25 @@ func FuzzHopData(f *testing.F) {
func FuzzHopPayload(f *testing.F) {
f.Fuzz(func(t *testing.T, data []byte) {
if len(data) > sphinx.MaxPayloadSize {
if len(data) > sphinx.MaxRoutingPayloadSize {
return
}
r := bytes.NewReader(data)
var hopPayload1, hopPayload2 sphinx.HopPayload
var hopPayload1, hopPayload2 *sphinx.HopPayload
tlvGuaranteed := false
if err := hopPayload1.Decode(r); err != nil {
hopPayload1, err := sphinx.DecodeHopPayload(r, tlvGuaranteed)
if err != nil {
return
}
var b bytes.Buffer
require.NoError(t, hopPayload1.Encode(&b))
require.NoError(t, hopPayload2.Decode(&b))
hopPayload2, err = sphinx.DecodeHopPayload(&b, tlvGuaranteed)
require.NoError(t, err)
require.Equal(t, hopPayload1, hopPayload2)
})
@ -129,7 +133,7 @@ func FuzzPayloadIntermediateNoBlinding(f *testing.F) {
func fuzzPayload(f *testing.F, finalPayload, updateAddBlinded bool) {
f.Fuzz(func(t *testing.T, data []byte) {
if len(data) > sphinx.MaxPayloadSize {
if len(data) > sphinx.MaxRoutingPayloadSize {
return
}

View file

@ -543,6 +543,10 @@ var allTestCases = []*lntest.TestCase{
Name: "onion message",
TestFunc: testOnionMessage,
},
{
Name: "onion message forwarding",
TestFunc: testOnionMessageForwarding,
},
{
Name: "sign verify message with addr",
TestFunc: testSignVerifyMessageWithAddr,

View file

@ -0,0 +1,343 @@
package itest
import (
"testing"
"time"
"github.com/btcsuite/btcd/btcec/v2"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lntest"
"github.com/lightningnetwork/lnd/lntest/node"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/onionmessage"
"github.com/lightningnetwork/lnd/record"
"github.com/stretchr/testify/require"
)
// onionMessageTestCase defines a test case for onion message forwarding.
type onionMessageTestCase struct {
name string
// setup is called before building the blinded path to perform any
// additional setup (e.g., opening channels for SCID tests).
setup func(ht *lntest.HarnessTest, alice, bob, carol *node.HarnessNode)
// buildPath builds the blinded path for the test. It returns the
// blinded path info, the final hop payloads, the first hop node,
// and the expected receiving peer pubkey for validation.
buildPath func(ht *lntest.HarnessTest, alice, bob,
carol *node.HarnessNode) (
blindedPath *sphinx.BlindedPathInfo,
finalHopTLVs []*lnwire.FinalHopTLV,
firstHop *node.HarnessNode,
expectedPeer []byte,
)
}
// testOnionMessageForwarding tests forwarding of onion messages across
// multiple scenarios including forwarding by node ID, by SCID, and with
// concatenated blinded paths.
func testOnionMessageForwarding(ht *lntest.HarnessTest) {
// Spin up three nodes for the test network.
alice := ht.NewNodeWithCoins("Alice", nil)
bob := ht.NewNodeWithCoins("Bob", nil)
carol := ht.NewNode("Carol", nil)
// Connect nodes so they can share gossip and forward messages.
ht.ConnectNodesPerm(alice, bob)
ht.ConnectNodesPerm(bob, carol)
testCases := []onionMessageTestCase{
{
name: "forward via next node id",
buildPath: func(ht *lntest.HarnessTest, alice, bob,
carol *node.HarnessNode) (
*sphinx.BlindedPathInfo,
[]*lnwire.FinalHopTLV,
*node.HarnessNode, []byte,
) {
return buildForwardNextNodePath(
ht, bob, carol,
)
},
},
{
name: "forward via scid",
setup: func(ht *lntest.HarnessTest, alice, bob,
carol *node.HarnessNode) {
// Open a channel between Bob and Carol so we
// have an SCID to use.
chanPoint := ht.OpenChannel(
bob, carol,
lntest.OpenChannelParams{Amt: 100000},
)
// Wait for the channel to be in the graph so
// the SCID can be resolved.
ht.AssertChannelInGraph(bob, chanPoint)
},
buildPath: func(ht *lntest.HarnessTest, alice, bob,
carol *node.HarnessNode) (
*sphinx.BlindedPathInfo,
[]*lnwire.FinalHopTLV,
*node.HarnessNode, []byte,
) {
return buildForwardSCIDPath(ht, bob, carol)
},
},
{
name: "forward concatenated path",
buildPath: buildConcatenatedPath,
},
}
for _, tc := range testCases {
success := ht.Run(tc.name, func(t *testing.T) {
// Run optional setup.
if tc.setup != nil {
tc.setup(ht, alice, bob, carol)
}
// Build the blinded path for this test case.
blindedPath, finalPayloads, firstHop, expectedPeer :=
tc.buildPath(ht, alice, bob, carol)
// Build the onion message.
onionMsg, _ := onionmessage.BuildOnionMessage(
ht.T, blindedPath, finalPayloads,
)
// Subscribe to onion messages on Carol before sending.
msgClient, cancel := carol.RPC.SubscribeOnionMessages()
defer cancel()
messages := make(chan *lnrpc.OnionMessageUpdate)
go func() {
for {
msg, err := msgClient.Recv()
if err != nil {
return
}
select {
case messages <- msg:
case <-ht.Context().Done():
return
}
}
}()
// Send the message from Alice to the first hop.
pathKey := blindedPath.SessionKey.PubKey().
SerializeCompressed()
aliceMsg := &lnrpc.SendOnionMessageRequest{
Peer: firstHop.PubKey[:],
PathKey: pathKey,
Onion: onionMsg.OnionBlob,
}
alice.RPC.SendOnionMessage(aliceMsg)
// Wait for Carol to receive the message.
select {
case msg := <-messages:
require.Equal(
ht, expectedPeer, msg.Peer,
"unexpected peer",
)
// Verify final payload if provided.
for _, fp := range finalPayloads {
tlvType := uint64(fp.TLVType)
require.Equal(
ht, fp.Value,
msg.CustomRecords[tlvType],
)
}
case <-time.After(lntest.DefaultTimeout):
ht.Fatalf("carol did not receive onion message")
}
})
if !success {
break
}
}
}
// buildForwardNextNodePath builds a blinded path for forwarding via explicit
// next node ID. Path: Alice -> Bob -> Carol.
func buildForwardNextNodePath(ht *lntest.HarnessTest, bob,
carol *node.HarnessNode) (
*sphinx.BlindedPathInfo, []*lnwire.FinalHopTLV,
*node.HarnessNode, []byte,
) {
bobPubKey, err := btcec.ParsePubKey(bob.PubKey[:])
require.NoError(ht.T, err)
carolPubKey, err := btcec.ParsePubKey(carol.PubKey[:])
require.NoError(ht.T, err)
// Bob's payload: forward to Carol via node ID.
nextNode := fn.NewLeft[*btcec.PublicKey, lnwire.ShortChannelID](
carolPubKey,
)
bobData := record.NewNonFinalBlindedRouteDataOnionMessage(
nextNode, nil, nil,
)
// Carol's payload: final hop (empty route data).
carolData := &record.BlindedRouteData{}
hops := []*sphinx.HopInfo{
{
NodePub: bobPubKey,
PlainText: onionmessage.EncodeBlindedRouteData(
ht.T, bobData,
),
},
{
NodePub: carolPubKey,
PlainText: onionmessage.EncodeBlindedRouteData(
ht.T, carolData,
),
},
}
blindedPath := onionmessage.BuildBlindedPath(ht.T, hops)
finalHopTLVs := []*lnwire.FinalHopTLV{
{
TLVType: lnwire.InvoiceRequestNamespaceType,
Value: []byte{1, 2, 3},
},
}
return blindedPath, finalHopTLVs, bob, bob.PubKey[:]
}
// buildForwardSCIDPath builds a blinded path for forwarding via SCID.
// Requires a channel between Bob and Carol to exist.
// Path: Alice -> Bob -> Carol (Bob uses SCID to identify Carol).
func buildForwardSCIDPath(ht *lntest.HarnessTest, bob,
carol *node.HarnessNode) (
*sphinx.BlindedPathInfo, []*lnwire.FinalHopTLV,
*node.HarnessNode, []byte,
) {
bobPubKey, err := btcec.ParsePubKey(bob.PubKey[:])
require.NoError(ht.T, err)
carolPubKey, err := btcec.ParsePubKey(carol.PubKey[:])
require.NoError(ht.T, err)
// Get the SCID of the Bob-Carol channel from Bob's perspective.
channels := bob.RPC.ListChannels(&lnrpc.ListChannelsRequest{
Peer: carol.PubKey[:],
})
require.Len(ht.T, channels.Channels, 1, "expected one channel")
scid := lnwire.NewShortChanIDFromInt(channels.Channels[0].ChanId)
// Bob's payload: forward to Carol via SCID.
nextNode := fn.NewRight[*btcec.PublicKey](scid)
bobData := record.NewNonFinalBlindedRouteDataOnionMessage(
nextNode, nil, nil,
)
// Carol's payload: final hop (empty route data).
carolData := &record.BlindedRouteData{}
hops := []*sphinx.HopInfo{
{
NodePub: bobPubKey,
PlainText: onionmessage.EncodeBlindedRouteData(
ht.T, bobData,
),
},
{
NodePub: carolPubKey,
PlainText: onionmessage.EncodeBlindedRouteData(
ht.T, carolData,
),
},
}
blindedPath := onionmessage.BuildBlindedPath(ht.T, hops)
finalHopTLVs := []*lnwire.FinalHopTLV{
{
TLVType: lnwire.InvoiceRequestNamespaceType,
Value: []byte{4, 5, 6},
},
}
return blindedPath, finalHopTLVs, bob, bob.PubKey[:]
}
// buildConcatenatedPath builds a concatenated blinded path scenario.
// Alice builds a path to Bob, Carol provides a blinded path starting at Bob.
// Bob's payload includes NextBlindingOverride to switch to Carol's path.
// Path: Alice -> Bob (intro) -> Carol.
func buildConcatenatedPath(ht *lntest.HarnessTest, alice, bob,
carol *node.HarnessNode) (
*sphinx.BlindedPathInfo, []*lnwire.FinalHopTLV,
*node.HarnessNode, []byte,
) {
bobPubKey, err := btcec.ParsePubKey(bob.PubKey[:])
require.NoError(ht.T, err)
carolPubKey, err := btcec.ParsePubKey(carol.PubKey[:])
require.NoError(ht.T, err)
// Carol creates a blinded path starting at Bob (introduction node).
// Carol's route data: final hop.
carolData := &record.BlindedRouteData{}
receiverHops := []*sphinx.HopInfo{
{
NodePub: carolPubKey,
PlainText: onionmessage.EncodeBlindedRouteData(
ht.T, carolData,
),
},
}
receiverPath := onionmessage.BuildBlindedPath(ht.T, receiverHops)
// Alice creates a path to Bob with NextBlindingOverride pointing to
// Carol's blinding point.
nextNode := fn.NewLeft[*btcec.PublicKey, lnwire.ShortChannelID](
carolPubKey,
)
bobData := record.NewNonFinalBlindedRouteDataOnionMessage(
nextNode, receiverPath.Path.BlindingPoint, nil,
)
senderHops := []*sphinx.HopInfo{
{
NodePub: bobPubKey,
PlainText: onionmessage.EncodeBlindedRouteData(
ht.T, bobData,
),
},
}
senderPath := onionmessage.BuildBlindedPath(ht.T, senderHops)
// Concatenate the paths.
concatenatedPath := onionmessage.ConcatBlindedPaths(
ht.T, senderPath, receiverPath,
)
finalHopTLVs := []*lnwire.FinalHopTLV{
{
TLVType: lnwire.InvoiceRequestNamespaceType,
Value: []byte{7, 8, 9},
},
}
return concatenatedPath, finalHopTLVs, bob, bob.PubKey[:]
}

View file

@ -4,8 +4,12 @@ import (
"time"
"github.com/btcsuite/btcd/btcec/v2"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lntest"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/onionmessage"
"github.com/lightningnetwork/lnd/record"
"github.com/stretchr/testify/require"
)
@ -20,7 +24,7 @@ func testOnionMessage(ht *lntest.HarnessTest) {
defer cancel()
// Create a channel to receive onion messages on.
messages := make(chan *lnrpc.OnionMessage)
messages := make(chan *lnrpc.OnionMessageUpdate)
go func() {
for {
// If we fail to receive, just exit. The test should
@ -44,34 +48,49 @@ func testOnionMessage(ht *lntest.HarnessTest) {
// Connect alice and bob so that they can exchange messages.
ht.EnsureConnected(alice, bob)
// Create a random onion message.
randomPriv, err := btcec.NewPrivateKey()
// Build a valid onion message destined for Alice.
alicePubKey, err := btcec.ParsePubKey(alice.PubKey[:])
require.NoError(ht.T, err)
randomPub := randomPriv.PubKey()
msgPathKey := randomPub.SerializeCompressed()
// Create a random payload. The content doesn't matter for this and
// doesn't need to be encrypted. It's also of arbitrary length, so it
// doesn't follow the BOLT 4 spec for onion message payload length of
// either 1300 or 32768 bytes. Here we just use a few bytes to keep it
// simple.
msgOnion := []byte{1, 2, 3}
// Alice is the final destination, so her route data is empty.
aliceData := &record.BlindedRouteData{}
hops := []*sphinx.HopInfo{
{
NodePub: alicePubKey,
PlainText: onionmessage.EncodeBlindedRouteData(
ht.T, aliceData,
),
},
}
blindedPath := onionmessage.BuildBlindedPath(ht.T, hops)
// Add a custom payload to verify it's received correctly.
finalHopTLVs := []*lnwire.FinalHopTLV{
{
TLVType: lnwire.InvoiceRequestNamespaceType,
Value: []byte{1, 2, 3},
},
}
onionMsg, _ := onionmessage.BuildOnionMessage(
ht.T, blindedPath, finalHopTLVs,
)
// Send it from Bob to Alice.
pathKey := blindedPath.SessionKey.PubKey().SerializeCompressed()
bobMsg := &lnrpc.SendOnionMessageRequest{
Peer: alice.PubKey[:],
PathKey: msgPathKey,
Onion: msgOnion,
PathKey: pathKey,
Onion: onionMsg.OnionBlob,
}
bob.RPC.SendOnionMessage(bobMsg)
// Wait for Alice to receive the message.
select {
case msg := <-messages:
// Check our type and data and (sanity) check the peer we got
// it from.
require.Equal(ht, msgOnion, msg.Onion, "msg data wrong")
require.Equal(ht, msgPathKey, msg.PathKey, "msg "+
"path key wrong")
// Check we received the message from Bob.
require.Equal(ht, bob.PubKey[:], msg.Peer, "msg peer wrong")
case <-time.After(lntest.DefaultTimeout):

View file

@ -71,6 +71,9 @@ type ProtocolOptions struct {
// NoRouteBlindingOption disables forwarding of payments in blinded routes.
NoRouteBlindingOption bool `long:"no-route-blinding" description:"do not forward payments that are a part of a blinded route"`
// NoOnionMessagesOption disables onion message forwarding.
NoOnionMessagesOption bool `long:"no-onion-messages" description:"disable support for onion messaging"`
// NoExperimentalAccountabilityOption disables experimental accountability.
NoExperimentalAccountabilityOption bool `long:"no-experimental-accountability" description:"do not forward experimental accountability signals"`
@ -144,6 +147,11 @@ func (l *ProtocolOptions) NoRouteBlinding() bool {
return l.NoRouteBlindingOption
}
// NoOnionMessages returns true if onion messaging is disabled.
func (l *ProtocolOptions) NoOnionMessages() bool {
return l.NoOnionMessagesOption
}
// NoExpAccountability returns true if experimental accountability should be
// disabled. It also checks the deprecated NoExperimentalEndorsementOption for
// backwards compatibility.

View file

@ -74,6 +74,9 @@ type ProtocolOptions struct {
// NoRouteBlindingOption disables forwarding of payments in blinded routes.
NoRouteBlindingOption bool `long:"no-route-blinding" description:"do not forward payments that are a part of a blinded route"`
// NoOnionMessagesOption disables onion message forwarding.
NoOnionMessagesOption bool `long:"no-onion-messages" description:"disable support for onion messaging"`
// NoExperimentalAccountabilityOption disables experimental accountability.
NoExperimentalAccountabilityOption bool `long:"no-experimental-accountability" description:"do not forward experimental accountability signals"`
@ -142,6 +145,11 @@ func (l *ProtocolOptions) NoRouteBlinding() bool {
return l.NoRouteBlindingOption
}
// NoOnionMessages returns true if onion messaging is disabled.
func (l *ProtocolOptions) NoOnionMessages() bool {
return l.NoOnionMessagesOption
}
// NoExpAccountability returns true if experimental accountability should be
// disabled. It also checks the deprecated NoExperimentalEndorsementOption for
// backwards compatibility.

File diff suppressed because it is too large Load diff

View file

@ -607,7 +607,7 @@ service Lightning {
SubscribeOnionMessages subscribes to a stream of incoming onion messages.
*/
rpc SubscribeOnionMessages (SubscribeOnionMessagesRequest)
returns (stream OnionMessage);
returns (stream OnionMessageUpdate);
/* lncli: `listaliases`
ListAliases returns the set of all aliases that have ever existed with
@ -676,7 +676,7 @@ message SendCustomMessageResponse {
message SubscribeOnionMessagesRequest {
}
message OnionMessage {
message OnionMessageUpdate {
// Peer from which this message originates. Represented as a byte-encoded
// public key.
bytes peer = 1;
@ -694,6 +694,20 @@ message OnionMessage {
// encrypted payloads and routing instructions used to forward this message
// along its designated path.
bytes onion = 3;
// reply_path is the blinded path that should be used when replying to a
// received message.
BlindedPath reply_path = 4;
// encrypted_recipient_data is the encrypted data that contains the
// forwarding information for an onion message. It contains either
// next_node_id or short_channel_id for each non-final node. It MAY contain
// the path_id for the final node.
bytes encrypted_recipient_data = 5;
// Custom onion message tlv records. These are customized fields that are
// not defined by LND and cannot be extracted.
map<uint64, bytes> custom_records = 6;
}
message SendOnionMessageRequest {

View file

@ -2308,13 +2308,13 @@
"type": "object",
"properties": {
"result": {
"$ref": "#/definitions/lnrpcOnionMessage"
"$ref": "#/definitions/lnrpcOnionMessageUpdate"
},
"error": {
"$ref": "#/definitions/rpcStatus"
}
},
"title": "Stream result of lnrpcOnionMessage"
"title": "Stream result of lnrpcOnionMessageUpdate"
}
},
"default": {
@ -6566,7 +6566,7 @@
}
}
},
"lnrpcOnionMessage": {
"lnrpcOnionMessageUpdate": {
"type": "object",
"properties": {
"peer": {
@ -6583,6 +6583,23 @@
"type": "string",
"format": "byte",
"description": "Serialized Sphinx onion packet (BOLT 4) containing the layered, per-hop\nencrypted payloads and routing instructions used to forward this message\nalong its designated path."
},
"reply_path": {
"$ref": "#/definitions/lnrpcBlindedPath",
"description": "reply_path is the blinded path that should be used when replying to a\nreceived message."
},
"encrypted_recipient_data": {
"type": "string",
"format": "byte",
"description": "encrypted_recipient_data is the encrypted data that contains the\nforwarding information for an onion message. It contains either\nnext_node_id or short_channel_id for each non-final node. It MAY contain\nthe path_id for the final node."
},
"custom_records": {
"type": "object",
"additionalProperties": {
"type": "string",
"format": "byte"
},
"description": "Custom onion message tlv records. These are customized fields that are\nnot defined by LND and cannot be extracted."
}
}
},

View file

@ -1369,7 +1369,7 @@ func (c *lightningClient) SubscribeOnionMessages(ctx context.Context, in *Subscr
}
type Lightning_SubscribeOnionMessagesClient interface {
Recv() (*OnionMessage, error)
Recv() (*OnionMessageUpdate, error)
grpc.ClientStream
}
@ -1377,8 +1377,8 @@ type lightningSubscribeOnionMessagesClient struct {
grpc.ClientStream
}
func (x *lightningSubscribeOnionMessagesClient) Recv() (*OnionMessage, error) {
m := new(OnionMessage)
func (x *lightningSubscribeOnionMessagesClient) Recv() (*OnionMessageUpdate, error) {
m := new(OnionMessageUpdate)
if err := x.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
@ -3346,7 +3346,7 @@ func _Lightning_SubscribeOnionMessages_Handler(srv interface{}, stream grpc.Serv
}
type Lightning_SubscribeOnionMessagesServer interface {
Send(*OnionMessage) error
Send(*OnionMessageUpdate) error
grpc.ServerStream
}
@ -3354,7 +3354,7 @@ type lightningSubscribeOnionMessagesServer struct {
grpc.ServerStream
}
func (x *lightningSubscribeOnionMessagesServer) Send(m *OnionMessage) error {
func (x *lightningSubscribeOnionMessagesServer) Send(m *OnionMessageUpdate) error {
return x.ServerStream.SendMsg(m)
}

View file

@ -317,6 +317,14 @@ const (
// support for the special custom taproot overlay channel.
SimpleTaprootOverlayChansRequired = 2026
// OnionMessagesRequired is a required feature bit that indicates that
// the node can forward onion messages.
OnionMessagesRequired = 38
// OnionMessagesOptional is an optional feature bit that indicates
// that the node can forward onion messages.
OnionMessagesOptional = 39
// MaxBolt11Feature is the maximum feature bit value allowed in bolt 11
// invoices.
//
@ -395,6 +403,8 @@ var Features = map[FeatureBit]string{
RbfCoopCloseRequired: "rbf-coop-close",
RbfCoopCloseOptionalStaging: "rbf-coop-close-x",
RbfCoopCloseRequiredStaging: "rbf-coop-close-x",
OnionMessagesOptional: "onion-messages",
OnionMessagesRequired: "onion-messages",
}
// RawFeatureVector represents a set of feature bits as defined in BOLT-09. A

412
lnwire/onion_msg_payload.go Normal file
View file

@ -0,0 +1,412 @@
package lnwire
import (
"bytes"
"errors"
"fmt"
"io"
"sort"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/tlv"
)
const (
// finalHopPayloadStart is the inclusive beginning of the tlv type
// range that is reserved for payloads for the final hop.
finalHopPayloadStart tlv.Type = 64
// replyPathType is a record for onion messaging reply paths.
replyPathType tlv.Type = 2
// encryptedDataTLVType is a record containing encrypted data for
// message recipient.
encryptedDataTLVType tlv.Type = 4
// InvoiceRequestNamespaceType is a record containing the sub-namespace
// of tlvs that request invoices for offers.
InvoiceRequestNamespaceType tlv.Type = 64
// InvoiceNamespaceType is a record containing the sub-namespace of
// tlvs that describe an invoice.
InvoiceNamespaceType tlv.Type = 66
// InvoiceErrorNamespaceType is a record containing the sub-namespace of
// tlvs that describe an invoice error.
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")
)
// 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
// EncryptedData contains encrypted data for the recipient.
EncryptedData []byte
// FinalHopTLVs contains any TLVs with type >= 64 that are reserved for
// the final hop's payload.
FinalHopTLVs []*FinalHopTLV
}
// NewOnionMessagePayload creates a new OnionMessagePayload.
func NewOnionMessagePayload() *OnionMessagePayload {
return &OnionMessagePayload{}
}
// Encode encodes an onion message's payload.
//
// This is part of the lnwire.Message interface.
func (o *OnionMessagePayload) Encode() ([]byte, error) {
var records []tlv.Record
if o.ReplyPath != nil {
records = append(records, replyPathRecord(o.ReplyPath))
}
if len(o.EncryptedData) != 0 {
record := tlv.MakePrimitiveRecord(
encryptedDataTLVType, &o.EncryptedData,
)
records = append(records, record)
}
for _, finalHopTLV := range o.FinalHopTLVs {
if err := finalHopTLV.Validate(); err != nil {
return nil, err
}
// Create a primitive record that just writes the final hop
// tlv's bytes as-is. The creating function should have
// encoded the value correctly.
record := tlv.MakePrimitiveRecord(
finalHopTLV.TLVType, &finalHopTLV.Value,
)
records = append(records, record)
}
// Sort our records just in case the final hop payload records were
// provided in the incorrect order.
tlv.SortRecords(records)
stream, err := tlv.NewStream(records...)
if err != nil {
return nil, fmt.Errorf("new stream: %w", err)
}
b := new(bytes.Buffer)
if err := stream.Encode(b); err != nil {
return nil, fmt.Errorf("encode stream: %w", err)
}
return b.Bytes(), nil
}
// Decode decodes an onion message's payload.
//
// This is part of the lnwire.Message interface.
func (o *OnionMessagePayload) Decode(r io.Reader) (map[tlv.Type][]byte, error) {
var (
invoicePayload = &FinalHopTLV{
TLVType: InvoiceNamespaceType,
}
invoiceErrorPayload = &FinalHopTLV{
TLVType: InvoiceErrorNamespaceType,
}
invoiceRequestPayload = &FinalHopTLV{
TLVType: InvoiceRequestNamespaceType,
}
)
// Create a non-nil entry so that we can directly decode into it.
o.ReplyPath = &sphinx.BlindedPath{}
records := []tlv.Record{
replyPathRecord(o.ReplyPath),
tlv.MakePrimitiveRecord(
encryptedDataTLVType, &o.EncryptedData,
),
// Add a record for invoice request sub-namespace so that we
// won't fail on the even tlv - reasoning below.
tlv.MakePrimitiveRecord(
InvoiceRequestNamespaceType,
&invoiceRequestPayload.Value,
),
// Add records to read invoice and invoice errors sub-namespaces
// out. Although this is technically one of our "final hop
// payload" tlvs, it is an even value, so we need to include it
// as a known tlv here, or decoding will fail. We decode
// directly into a final hop payload, so that we can just add it
// if present later.
tlv.MakePrimitiveRecord(
InvoiceNamespaceType,
&invoicePayload.Value,
),
tlv.MakePrimitiveRecord(
InvoiceErrorNamespaceType,
&invoiceErrorPayload.Value,
),
}
stream, err := tlv.NewStream(records...)
if err != nil {
return nil, fmt.Errorf("new stream: %w", err)
}
tlvMap, err := stream.DecodeWithParsedTypesP2P(r)
if err != nil {
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
}
// Once we're decoded our message, we want to also include any tlvs
// that are intended for the final hop's payload which we may not have
// recognized. We'll just directly read these out and allow higher
// application layers to deal with them.
for tlvType, tlvBytes := range tlvMap {
// Skip any tlvs that are not in our range.
if tlvType < finalHopPayloadStart {
continue
}
// Skip any tlvs that have been recognized in our decoding (a
// zero entry means that we recognized the entry).
if len(tlvBytes) == 0 {
continue
}
// Add the payload to our message's final hop payloads.
payload := &FinalHopTLV{
TLVType: tlvType,
Value: tlvBytes,
}
o.FinalHopTLVs = append(
o.FinalHopTLVs, payload,
)
}
// If we read out an invoice, invoice error or invoice request tlv
// sub-namespace, add it to our set of final payloads. This value won't
// have been added in the loop above, because we recognized the TLV so
// len(tlvMap[invoiceType].tlvBytes) will be zero (thus, skipped above).
if _, ok := tlvMap[InvoiceNamespaceType]; ok {
o.FinalHopTLVs = append(
o.FinalHopTLVs, invoicePayload,
)
}
if _, ok := tlvMap[InvoiceErrorNamespaceType]; ok {
o.FinalHopTLVs = append(
o.FinalHopTLVs, invoiceErrorPayload,
)
}
if _, ok := tlvMap[InvoiceRequestNamespaceType]; ok {
o.FinalHopTLVs = append(
o.FinalHopTLVs, invoiceRequestPayload,
)
}
// Iteration through maps occurs in random order - sort final hop
// TLVs in ascending order to make this decoding function
// deterministic.
sort.SliceStable(o.FinalHopTLVs, func(i, j int) bool {
return o.FinalHopTLVs[i].TLVType <
o.FinalHopTLVs[j].TLVType
})
return tlvMap, nil
}
// FinalHopTLV contains values reserved for the final hop, which are just
// directly read from the tlv stream.
type FinalHopTLV struct {
// TLVType is the type for the payload.
TLVType tlv.Type
// Value is the raw byte value read for this tlv type. This field is
// expected to contain "sub-tlv" namespaces, and will require further
// decoding to be used.
Value []byte
}
// Validate performs validation of items added to the final hop's payload in an
// onion. This function returns an error if a tlv is not within the range
// reserved for final payload.
func (f *FinalHopTLV) Validate() error {
if f.TLVType < finalHopPayloadStart {
return fmt.Errorf("%w: %v", ErrNotFinalPayload, f.TLVType)
}
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)
}

View file

@ -0,0 +1,492 @@
package lnwire
import (
"bytes"
"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 {
t.Helper()
introKey, err := randPubKey()
require.NoError(t, err)
blindingKey, err := randPubKey()
require.NoError(t, err)
hops := make([]*sphinx.BlindedHopInfo, 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),
}
}
return &sphinx.BlindedPath{
IntroductionPoint: introKey,
BlindingPoint: blindingKey,
BlindedHops: hops,
}
}
// assertBlindedPathEqual compares two BlindedPaths for equality, checking each
// field.
func assertBlindedPathEqual(t *testing.T, expected,
actual *sphinx.BlindedPath) {
t.Helper()
require.True(
t,
expected.IntroductionPoint.IsEqual(actual.IntroductionPoint),
"IntroductionPoint mismatch",
)
require.True(
t, expected.BlindingPoint.IsEqual(actual.BlindingPoint),
"BlindingPoint mismatch",
)
require.Len(t, actual.BlindedHops, len(expected.BlindedHops))
for i, expectedHop := range expected.BlindedHops {
actualHop := actual.BlindedHops[i]
require.True(
t,
expectedHop.BlindedNodePub.IsEqual(
actualHop.BlindedNodePub,
),
"hop %d: BlindedNodePub mismatch", i,
)
require.Equal(
t, expectedHop.CipherText, actualHop.CipherText,
"hop %d: CipherText mismatch", i,
)
}
}
// encodeAndDecode is a helper that encodes a payload and decodes it into a
// fresh OnionMessagePayload.
func encodeAndDecode(t *testing.T,
original *OnionMessagePayload) *OnionMessagePayload {
t.Helper()
encoded, err := original.Encode()
require.NoError(t, err)
decoded := NewOnionMessagePayload()
_, err = decoded.Decode(bytes.NewReader(encoded))
require.NoError(t, err)
return decoded
}
// TestOnionMessagePayloadRoundTrip tests encode/decode roundtrips for various
// payload configurations.
func TestOnionMessagePayloadRoundTrip(t *testing.T) {
t.Parallel()
t.Run("only reply path", func(t *testing.T) {
t.Parallel()
original := &OnionMessagePayload{
ReplyPath: makeBlindedPath(t, 3),
}
decoded := encodeAndDecode(t, original)
require.NotNil(t, decoded.ReplyPath)
assertBlindedPathEqual(t, original.ReplyPath, decoded.ReplyPath)
require.Empty(t, decoded.EncryptedData)
require.Empty(t, decoded.FinalHopTLVs)
})
t.Run("only encrypted data", func(t *testing.T) {
t.Parallel()
original := &OnionMessagePayload{
EncryptedData: []byte("encrypted-recipient-data"),
}
decoded := encodeAndDecode(t, original)
require.Nil(t, decoded.ReplyPath)
require.Equal(
t, original.EncryptedData, decoded.EncryptedData,
)
require.Empty(t, decoded.FinalHopTLVs)
})
t.Run("reply path and encrypted data", func(t *testing.T) {
t.Parallel()
original := &OnionMessagePayload{
ReplyPath: makeBlindedPath(t, 2),
EncryptedData: []byte("test-ciphertext"),
}
decoded := encodeAndDecode(t, original)
require.NotNil(t, decoded.ReplyPath)
assertBlindedPathEqual(t, original.ReplyPath, decoded.ReplyPath)
require.Equal(
t, original.EncryptedData, decoded.EncryptedData,
)
require.Empty(t, decoded.FinalHopTLVs)
})
t.Run("single hop reply path", func(t *testing.T) {
t.Parallel()
original := &OnionMessagePayload{
ReplyPath: makeBlindedPath(t, 1),
}
decoded := encodeAndDecode(t, original)
require.NotNil(t, decoded.ReplyPath)
assertBlindedPathEqual(t, original.ReplyPath, decoded.ReplyPath)
})
t.Run("final hop TLVs", func(t *testing.T) {
t.Parallel()
original := &OnionMessagePayload{
EncryptedData: []byte("ciphertext"),
FinalHopTLVs: []*FinalHopTLV{
{
TLVType: InvoiceRequestNamespaceType,
Value: []byte("invoice-request"),
},
},
}
decoded := encodeAndDecode(t, original)
require.Equal(
t, original.EncryptedData, decoded.EncryptedData,
)
require.Len(t, decoded.FinalHopTLVs, 1)
require.Equal(
t, InvoiceRequestNamespaceType,
decoded.FinalHopTLVs[0].TLVType,
)
require.Equal(
t, original.FinalHopTLVs[0].Value,
decoded.FinalHopTLVs[0].Value,
)
})
t.Run("multiple final hop TLVs", func(t *testing.T) {
t.Parallel()
original := &OnionMessagePayload{
FinalHopTLVs: []*FinalHopTLV{
{
TLVType: InvoiceRequestNamespaceType,
Value: []byte("request"),
},
{
TLVType: InvoiceNamespaceType,
Value: []byte("invoice"),
},
{
TLVType: InvoiceErrorNamespaceType,
Value: []byte("error"),
},
},
}
decoded := encodeAndDecode(t, original)
require.Nil(t, decoded.ReplyPath)
require.Len(t, decoded.FinalHopTLVs, 3)
// Decoded TLVs should be sorted by type.
require.Equal(
t, InvoiceRequestNamespaceType,
decoded.FinalHopTLVs[0].TLVType,
)
require.Equal(
t, InvoiceNamespaceType,
decoded.FinalHopTLVs[1].TLVType,
)
require.Equal(
t, InvoiceErrorNamespaceType,
decoded.FinalHopTLVs[2].TLVType,
)
})
t.Run("all fields populated", func(t *testing.T) {
t.Parallel()
original := &OnionMessagePayload{
ReplyPath: makeBlindedPath(t, 2),
EncryptedData: []byte("encrypted-data"),
FinalHopTLVs: []*FinalHopTLV{
{
TLVType: InvoiceNamespaceType,
Value: []byte("invoice-data"),
},
},
}
decoded := encodeAndDecode(t, original)
require.NotNil(t, decoded.ReplyPath)
assertBlindedPathEqual(t, original.ReplyPath, decoded.ReplyPath)
require.Equal(
t, original.EncryptedData, decoded.EncryptedData,
)
require.Len(t, decoded.FinalHopTLVs, 1)
require.Equal(
t, original.FinalHopTLVs[0].Value,
decoded.FinalHopTLVs[0].Value,
)
})
t.Run("odd unknown final hop TLV", func(t *testing.T) {
t.Parallel()
// Odd TLV types >= 64 that we don't explicitly recognize
// should be preserved as FinalHopTLVs.
original := &OnionMessagePayload{
FinalHopTLVs: []*FinalHopTLV{
{
TLVType: 65,
Value: []byte("custom-data"),
},
},
}
decoded := encodeAndDecode(t, original)
require.Len(t, decoded.FinalHopTLVs, 1)
require.Equal(t, tlv.Type(65), decoded.FinalHopTLVs[0].TLVType)
require.Equal(
t, []byte("custom-data"),
decoded.FinalHopTLVs[0].Value,
)
})
}
// TestFinalHopTLVValidate tests that FinalHopTLV.Validate correctly rejects
// types below the final hop range and accepts types within it.
func TestFinalHopTLVValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
recordType tlv.Type
wantErr error
}{
{
name: "type 0 rejected",
recordType: 0,
wantErr: ErrNotFinalPayload,
},
{
name: "type 2 rejected",
recordType: 2,
wantErr: ErrNotFinalPayload,
},
{
name: "type 63 rejected",
recordType: 63,
wantErr: ErrNotFinalPayload,
},
{
name: "type 64 accepted",
recordType: 64,
},
{
name: "type 65 accepted",
recordType: 65,
},
{
name: "type 255 accepted",
recordType: 255,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
f := &FinalHopTLV{
TLVType: tc.recordType,
Value: []byte("value"),
}
err := f.Validate()
if tc.wantErr != nil {
require.ErrorIs(t, err, tc.wantErr)
} else {
require.NoError(t, err)
}
})
}
}
// TestOnionMessagePayloadEncodeReplyPathNoHops tests that encoding a reply path
// with zero hops returns an error.
func TestOnionMessagePayloadEncodeReplyPathNoHops(t *testing.T) {
t.Parallel()
introKey, err := randPubKey()
require.NoError(t, err)
blindingKey, err := randPubKey()
require.NoError(t, err)
payload := &OnionMessagePayload{
ReplyPath: &sphinx.BlindedPath{
IntroductionPoint: introKey,
BlindingPoint: blindingKey,
BlindedHops: nil,
},
}
_, err = payload.Encode()
require.ErrorIs(t, err, ErrNoHops)
}
// TestOnionMessagePayloadEmpty tests that an empty payload roundtrips
// correctly.
func TestOnionMessagePayloadEmpty(t *testing.T) {
t.Parallel()
original := NewOnionMessagePayload()
decoded := encodeAndDecode(t, original)
require.Nil(t, decoded.ReplyPath)
require.Empty(t, decoded.EncryptedData)
require.Empty(t, decoded.FinalHopTLVs)
}
// TestOnionMessagePayloadRoundTripQuickCheck uses property-based testing to
// verify that randomly generated OnionMessagePayload values survive
// encode/decode roundtrips.
func TestOnionMessagePayloadRoundTripQuickCheck(t *testing.T) {
t.Parallel()
rapid.Check(t, func(t *rapid.T) {
original := &OnionMessagePayload{}
// Optionally include a reply path.
if rapid.Bool().Draw(t, "hasReplyPath") {
original.ReplyPath = RandBlindedPath(t)
}
// Optionally include encrypted data.
if rapid.Bool().Draw(t, "hasEncryptedData") {
dataLen := rapid.IntRange(1, 256).Draw(
t, "encryptedDataLen",
)
original.EncryptedData = rapid.SliceOfN(
rapid.Byte(), dataLen, dataLen,
).Draw(t, "encryptedData")
}
// Optionally include final hop TLVs. We use the three known
// even types (64, 66, 68) since unknown even types would cause
// decode to fail.
knownTypes := []tlv.Type{
InvoiceRequestNamespaceType,
InvoiceNamespaceType,
InvoiceErrorNamespaceType,
}
numFinalTLVs := rapid.IntRange(0, len(knownTypes)).Draw(
t, "numFinalTLVs",
)
for i := range numFinalTLVs {
valLen := rapid.IntRange(1, 64).Draw(
t, fmt.Sprintf("finalTLVLen-%d", i),
)
original.FinalHopTLVs = append(
original.FinalHopTLVs,
&FinalHopTLV{
TLVType: knownTypes[i],
Value: rapid.SliceOfN(
rapid.Byte(), valLen, valLen,
).Draw(
t,
fmt.Sprintf("finalTLV-%d", i),
),
},
)
}
// Encode.
encoded, err := original.Encode()
require.NoError(t, err)
// Decode.
decoded := NewOnionMessagePayload()
_, err = decoded.Decode(bytes.NewReader(encoded))
require.NoError(t, err)
// Verify reply path.
if original.ReplyPath == nil {
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),
)
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.
require.Equal(
t, original.EncryptedData, decoded.EncryptedData,
)
// Verify final hop TLVs.
require.Len(
t, decoded.FinalHopTLVs,
len(original.FinalHopTLVs),
)
for i, orig := range original.FinalHopTLVs {
dec := decoded.FinalHopTLVs[i]
require.Equal(t, orig.TLVType, dec.TLVType)
require.Equal(t, orig.Value, dec.Value)
}
})
}

View file

@ -9,6 +9,7 @@ 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"
@ -58,6 +59,33 @@ 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)
numHops := rapid.IntRange(1, 5).Draw(t, "numBlindedHops")
hops := make([]*sphinx.BlindedHopInfo, 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)),
}
}
return &sphinx.BlindedPath{
IntroductionPoint: introKey,
BlindingPoint: blindingKey,
BlindedHops: hops,
}
}
// RandChannelID generates a random channel ID.
func RandChannelID(t *rapid.T) ChannelID {
var c ChannelID

View file

@ -50,7 +50,7 @@ type Endpoint interface {
SendMessage(ctx context.Context, msg PeerMsg) bool
}
// MsgRouter is an interface that represents a message router, which is generic
// Router is an interface that represents a message router, which is generic
// sub-system capable of routing any incoming wire message to a set of
// registered endpoints.
type Router interface {

251
onionmessage/actor.go Normal file
View file

@ -0,0 +1,251 @@
package onionmessage
import (
"context"
"encoding/hex"
"log/slog"
"github.com/btcsuite/btclog/v2"
"github.com/lightningnetwork/lnd/actor"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/lnutils"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/record"
)
// Request is a message sent to an OnionPeerActor when an onion message is
// received from the peer. The actor processes the message through the full
// onion message pipeline: decode, decrypt, route, and forward/deliver.
type Request struct {
// Embed BaseMessage to satisfy the actor package Message interface.
actor.BaseMessage
// msg is the onion message to process. This field is unexported as
// it's an implementation detail of the actor system and should not be
// accessed directly by external code.
msg lnwire.OnionMessage
}
// NewRequest creates a new Request from an onion message.
func NewRequest(msg lnwire.OnionMessage) *Request {
return &Request{msg: msg}
}
// MessageType returns a string identifier for the Request message type.
func (m *Request) MessageType() string {
return "OnionMessageRequest"
}
// Response is the response message sent back from an OnionPeerActor after
// processing an incoming onion message.
type Response struct {
actor.BaseMessage
Success bool
}
// MessageType returns a string identifier for the Response message type.
func (m *Response) MessageType() string {
return "OnionMessageResponse"
}
// OnionPeerActorRef is a reference to an OnionPeerActor.
type OnionPeerActorRef actor.ActorRef[*Request, *Response]
// NewOnionMessageServiceKey creates a service key for registering and looking
// up onion peer actors. The service key uses the peer's compressed public key
// (hex-encoded) as the identifier. It returns both the service key and the
// hex-encoded public key string for use in actor naming and logging.
func NewOnionMessageServiceKey(
pubKey [33]byte) (actor.ServiceKey[*Request, *Response], string) {
pubKeyHex := hex.EncodeToString(pubKey[:])
return actor.NewServiceKey[*Request, *Response](pubKeyHex), pubKeyHex
}
// OnionActorFactory is a function that spawns a new OnionPeerActor for a
// given peer within the actor system. The factory captures shared dependencies
// (router, resolver, sender, dispatcher) and only requires per-peer parameters
// at spawn time.
type OnionActorFactory func(system *actor.ActorSystem,
peerPubKey [33]byte) (OnionPeerActorRef, error)
// OnionPeerActor handles the full onion message processing pipeline for a
// specific peer connection. It decodes incoming onion messages, determines
// the routing action (forward or deliver), executes the action, and dispatches
// updates to subscribers.
type OnionPeerActor struct {
// peerPubKey is the compressed public key of the peer this actor
// handles messages for.
peerPubKey [33]byte
// peerSender is used to forward onion messages to other peers.
peerSender PeerMessageSender
// router is the onion router used to process onion message packets.
router OnionRouter
// resolver resolves node public keys from short channel IDs.
resolver NodeIDResolver
// updateDispatcher dispatches onion message updates to subscribers.
updateDispatcher OnionMessageUpdateDispatcher
}
// Receive processes an incoming onion message from the peer. It decodes the
// onion packet, determines whether to forward or deliver the message, executes
// the routing action, and dispatches an update to subscribers.
//
// This method implements the actor.ActorBehavior interface.
func (a *OnionPeerActor) Receive(ctx context.Context,
req *Request) fn.Result[*Response] {
select {
case <-ctx.Done():
log.DebugS(ctx, "OnionPeerActor context canceled, "+
"not processing")
return fn.Err[*Response](ErrActorShuttingDown)
default:
}
logCtx := btclog.WithCtx(ctx,
slog.String("peer",
hex.EncodeToString(a.peerPubKey[:])),
lnutils.LogPubKey("path_key", req.msg.PathKey),
)
log.DebugS(logCtx, "OnionPeerActor received OnionMessage",
btclog.HexN("onion_blob", req.msg.OnionBlob, 10),
slog.Int("blob_length", len(req.msg.OnionBlob)))
routingActionResult := processOnionMessage(
ctx, a.router, a.resolver, &req.msg,
)
routingAction, err := routingActionResult.Unpack()
if err != nil {
log.ErrorS(logCtx, "Failed to handle onion message", err)
return fn.Err[*Response](err)
}
// Handle the routing action.
payload := fn.ElimEither(routingAction,
func(fwdAction forwardAction) *lnwire.OnionMessagePayload {
log.DebugS(logCtx, "Forwarding onion message",
lnutils.LogPubKey("next_node_id",
fwdAction.nextNodeID),
)
nextMsg := lnwire.NewOnionMessage(
fwdAction.nextPathKey,
fwdAction.nextPacket,
)
var nextNodeIDBytes [33]byte
copy(
nextNodeIDBytes[:],
fwdAction.nextNodeID.SerializeCompressed(),
)
sendErr := a.peerSender.SendToPeer(
nextNodeIDBytes, nextMsg,
)
if sendErr != nil {
log.ErrorS(logCtx, "Failed to forward "+
"onion message", sendErr)
}
return fwdAction.payload
},
func(dlvrAction deliverAction) *lnwire.OnionMessagePayload {
log.DebugS(logCtx, "Delivering onion message "+
"to self")
return dlvrAction.payload
})
// Convert path key to [33]byte.
var pathKeyArr [33]byte
copy(pathKeyArr[:], req.msg.PathKey.SerializeCompressed())
// Create the onion message update to send to subscribers.
update := &OnionMessageUpdate{
Peer: a.peerPubKey,
PathKey: pathKeyArr,
OnionBlob: req.msg.OnionBlob,
}
// If we have a payload, add its contents to our update.
if payload != nil {
customRecords := make(record.CustomSet)
for _, v := range payload.FinalHopTLVs {
customRecords[uint64(v.TLVType)] = v.Value
}
update.CustomRecords = customRecords
update.ReplyPath = payload.ReplyPath
update.EncryptedRecipientData = payload.EncryptedData
}
// Send the update to any subscribers.
if sendErr := a.updateDispatcher.SendUpdate(update); sendErr != nil {
log.ErrorS(logCtx, "Failed to send onion message update",
sendErr)
return fn.Err[*Response](sendErr)
}
return fn.Ok(&Response{Success: true})
}
// NewOnionActorFactory creates a factory function that spawns OnionPeerActors
// with shared dependencies. The returned factory captures the router,
// resolver, peer sender, and update dispatcher, requiring only the actor
// system and peer public key at spawn time.
func NewOnionActorFactory(router OnionRouter, resolver NodeIDResolver,
peerSender PeerMessageSender,
dispatcher OnionMessageUpdateDispatcher) OnionActorFactory {
return func(system *actor.ActorSystem,
peerPubKey [33]byte) (OnionPeerActorRef, error) {
peerActor := &OnionPeerActor{
peerPubKey: peerPubKey,
peerSender: peerSender,
router: router,
resolver: resolver,
updateDispatcher: dispatcher,
}
serviceKey, pubKeyHex := NewOnionMessageServiceKey(
peerPubKey,
)
actorRef, err := serviceKey.Spawn(
system, "onion-peer-actor-"+pubKeyHex, peerActor,
)
if err != nil {
return nil, err
}
log.Debugf("Spawned onion peer actor for peer %s",
pubKeyHex)
return actorRef, nil
}
}
// StopOnionActor stops the onion peer actor for the given public key using the
// provided actor reference. This should be called when a peer disconnects to
// clean up the actor.
func StopOnionActor(system *actor.ActorSystem, pubKey [33]byte,
ref OnionPeerActorRef) {
serviceKey, pubKeyHex := NewOnionMessageServiceKey(pubKey)
log.Debugf("Stopping onion peer actor for peer %s", pubKeyHex)
serviceKey.Unregister(
system, actor.ActorRef[*Request, *Response](ref),
)
}

542
onionmessage/actor_test.go Normal file
View file

@ -0,0 +1,542 @@
package onionmessage
import (
"context"
"fmt"
"testing"
"github.com/btcsuite/btcd/btcec/v2"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/record"
"github.com/stretchr/testify/require"
)
// mockPeerMessageSender implements PeerMessageSender for testing.
type mockPeerMessageSender struct {
sent chan peerMessage
err error
}
type peerMessage struct {
pubKey [33]byte
msg *lnwire.OnionMessage
}
func newMockPeerMessageSender() *mockPeerMessageSender {
return &mockPeerMessageSender{
sent: make(chan peerMessage, 1),
}
}
func (m *mockPeerMessageSender) SendToPeer(pubKey [33]byte,
msg *lnwire.OnionMessage) error {
if m.err != nil {
return m.err
}
m.sent <- peerMessage{pubKey: pubKey, msg: msg}
return nil
}
// mockUpdateDispatcher implements OnionMessageUpdateDispatcher for testing.
type mockUpdateDispatcher struct {
updates chan *OnionMessageUpdate
err error
}
func newMockUpdateDispatcher() *mockUpdateDispatcher {
return &mockUpdateDispatcher{
updates: make(chan *OnionMessageUpdate, 1),
}
}
func (m *mockUpdateDispatcher) SendUpdate(update any) error {
if m.err != nil {
return m.err
}
u, ok := update.(*OnionMessageUpdate)
if !ok {
return fmt.Errorf("unexpected update type: %T", update)
}
m.updates <- u
return nil
}
// actorHarness wires up the minimal components required to exercise
// OnionPeerActor.Receive end-to-end.
type actorHarness struct {
actor *OnionPeerActor
sender *mockPeerMessageSender
dispatcher *mockUpdateDispatcher
resolver *mockNodeIDResolver
router *sphinx.Router
nodeKey *btcec.PrivateKey
}
func newActorHarness(t *testing.T) *actorHarness {
t.Helper()
nodeKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
router := sphinx.NewRouter(
&sphinx.PrivKeyECDH{PrivKey: nodeKey},
sphinx.NewNoOpReplayLog(),
)
require.NoError(t, router.Start())
t.Cleanup(func() { router.Stop() })
sender := newMockPeerMessageSender()
dispatcher := newMockUpdateDispatcher()
resolver := newMockNodeIDResolver()
var peerPubKey [33]byte
copy(peerPubKey[:], nodeKey.PubKey().SerializeCompressed())
peerActor := &OnionPeerActor{
peerPubKey: peerPubKey,
peerSender: sender,
router: router,
resolver: resolver,
updateDispatcher: dispatcher,
}
return &actorHarness{
actor: peerActor,
sender: sender,
dispatcher: dispatcher,
resolver: resolver,
router: router,
nodeKey: nodeKey,
}
}
func pubKeyToArray(pk *btcec.PublicKey) [33]byte {
var out [33]byte
copy(out[:], pk.SerializeCompressed())
return out
}
// hopBuildResult encapsulates the outputs of a hop building function.
type hopBuildResult struct {
blindedPath *sphinx.BlindedPathInfo
privKeys []*btcec.PrivateKey
after func()
}
// buildHopsFunc is the signature for functions that construct test hop data.
type buildHopsFunc func(t *testing.T, h *actorHarness) hopBuildResult
// buildForwardNextNodeHops constructs hops for testing forward via next node.
func buildForwardNextNodeHops(
t *testing.T, h *actorHarness) hopBuildResult {
nextNodeKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
nextNodePub := nextNodeKey.PubKey()
nextNode := fn.NewLeft[*btcec.PublicKey, lnwire.ShortChannelID](
nextNodePub,
)
rdA := record.NewNonFinalBlindedRouteDataOnionMessage(
nextNode, nil, nil,
)
rdB := &record.BlindedRouteData{}
plainA := EncodeBlindedRouteData(t, rdA)
plainB := EncodeBlindedRouteData(t, rdB)
hops := []*sphinx.HopInfo{
{NodePub: h.nodeKey.PubKey(), PlainText: plainA},
{NodePub: nextNodePub, PlainText: plainB},
}
privKeys := []*btcec.PrivateKey{h.nodeKey, nextNodeKey}
after := func() {
select {
case msg := <-h.sender.sent:
require.NotNil(t, msg.msg)
require.Equal(
t, pubKeyToArray(nextNodePub), msg.pubKey,
)
default:
require.FailNow(t, "forwarded message not sent")
}
}
return hopBuildResult{
blindedPath: BuildBlindedPath(t, hops),
privKeys: privKeys,
after: after,
}
}
// buildForwardSCIDHops constructs hops for testing forward via SCID.
func buildForwardSCIDHops(t *testing.T, h *actorHarness) hopBuildResult {
nextNodeKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
nextNodePub := nextNodeKey.PubKey()
scid := lnwire.NewShortChanIDFromInt(555)
h.resolver.addPeer(scid, nextNodePub)
nextNode := fn.NewRight[*btcec.PublicKey](scid)
rdA := record.NewNonFinalBlindedRouteDataOnionMessage(
nextNode, nil, nil,
)
rdB := &record.BlindedRouteData{}
plainA := EncodeBlindedRouteData(t, rdA)
plainB := EncodeBlindedRouteData(t, rdB)
hops := []*sphinx.HopInfo{
{NodePub: h.nodeKey.PubKey(), PlainText: plainA},
{NodePub: nextNodePub, PlainText: plainB},
}
privKeys := []*btcec.PrivateKey{h.nodeKey, nextNodeKey}
after := func() {
select {
case msg := <-h.sender.sent:
require.NotNil(t, msg.msg)
default:
require.FailNow(t, "forwarded message not sent")
}
}
return hopBuildResult{
blindedPath: BuildBlindedPath(t, hops),
privKeys: privKeys,
after: after,
}
}
// buildDeliverHops constructs hops for testing the deliver action.
func buildDeliverHops(t *testing.T, h *actorHarness) hopBuildResult {
rd := &record.BlindedRouteData{}
plain := EncodeBlindedRouteData(t, rd)
hops := []*sphinx.HopInfo{
{NodePub: h.nodeKey.PubKey(), PlainText: plain},
}
privKeys := []*btcec.PrivateKey{h.nodeKey}
return hopBuildResult{
blindedPath: BuildBlindedPath(t, hops),
privKeys: privKeys,
after: func() {},
}
}
// buildForwardUnknownPeerHops constructs hops for testing forward to an
// unknown peer. The sender returns an error, so forwarding will fail but the
// message is still processed.
func buildForwardUnknownPeerHops(
t *testing.T, h *actorHarness) hopBuildResult {
nextNodeKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
nextNodePub := nextNodeKey.PubKey()
// Set up the sender to return an error for the unknown peer.
h.sender.err = fmt.Errorf("peer not connected")
nextNode := fn.NewLeft[*btcec.PublicKey, lnwire.ShortChannelID](
nextNodePub,
)
rdA := record.NewNonFinalBlindedRouteDataOnionMessage(
nextNode, nil, nil,
)
rdB := &record.BlindedRouteData{}
hops := []*sphinx.HopInfo{
{
NodePub: h.nodeKey.PubKey(),
PlainText: EncodeBlindedRouteData(t, rdA),
},
{
NodePub: nextNodePub,
PlainText: EncodeBlindedRouteData(t, rdB),
},
}
privKeys := []*btcec.PrivateKey{h.nodeKey, nextNodeKey}
after := func() {
// Verify no message was successfully sent.
select {
case <-h.sender.sent:
require.FailNow(t, "message should not have been "+
"forwarded to unknown peer")
default:
// Expected: no forwarding happened.
}
}
return hopBuildResult{
blindedPath: BuildBlindedPath(t, hops),
privKeys: privKeys,
after: after,
}
}
// buildConcatenatedPathHops constructs a concatenated blinded path scenario.
func buildConcatenatedPathHops(
t *testing.T, h *actorHarness) hopBuildResult {
introNodeKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
introNodePub := introNodeKey.PubKey()
finalNodeKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
finalNodePub := finalNodeKey.PubKey()
// Build the receiver's blinded path: introNode -> finalNode.
nextNodeReceiver := fn.NewLeft[*btcec.PublicKey, lnwire.ShortChannelID](
finalNodePub,
)
rdReceiverIntro := record.NewNonFinalBlindedRouteDataOnionMessage(
nextNodeReceiver, nil, nil,
)
rdReceiverFinal := &record.BlindedRouteData{}
receiverHops := []*sphinx.HopInfo{
{
NodePub: introNodePub,
PlainText: EncodeBlindedRouteData(
t, rdReceiverIntro,
),
},
{
NodePub: finalNodePub,
PlainText: EncodeBlindedRouteData(
t, rdReceiverFinal,
),
},
}
receiverPath := BuildBlindedPath(t, receiverHops)
// Build the sender's path: firstHopNode -> introNode.
nextNodeSender := fn.NewLeft[*btcec.PublicKey, lnwire.ShortChannelID](
introNodePub,
)
blindingOverride := receiverPath.Path.BlindingPoint
rdFirstHop := record.NewNonFinalBlindedRouteDataOnionMessage(
nextNodeSender, blindingOverride, nil,
)
senderHops := []*sphinx.HopInfo{
{
NodePub: h.nodeKey.PubKey(),
PlainText: EncodeBlindedRouteData(
t, rdFirstHop,
),
},
}
senderPath := BuildBlindedPath(t, senderHops)
concatenatedPath := ConcatBlindedPaths(
t, senderPath, receiverPath,
)
privKeys := []*btcec.PrivateKey{h.nodeKey, introNodeKey, finalNodeKey}
expectedPathKey := blindingOverride
after := func() {
select {
case msg := <-h.sender.sent:
require.NotNil(t, msg.msg)
// Verify the forwarded message uses the receiver's
// blinding point as the new path key.
require.Equal(
t, expectedPathKey, msg.msg.PathKey,
"forwarded message should use override "+
"path key",
)
default:
require.FailNow(t, "forwarded message not sent")
}
}
return hopBuildResult{
blindedPath: concatenatedPath,
privKeys: privKeys,
after: after,
}
}
// TestOnionPeerActorRouting tests the OnionPeerActor's message routing
// functionality across various scenarios including forwarding via next node ID,
// forwarding via SCID, delivery, concatenated paths, and unknown peer handling.
func TestOnionPeerActorRouting(t *testing.T) {
t.Parallel()
customTLVType := lnwire.InvoiceRequestNamespaceType + 1
tests := []struct {
name string
buildHops buildHopsFunc
finalHopTLVs []*lnwire.FinalHopTLV
}{
{
name: "forward next node",
buildHops: buildForwardNextNodeHops,
},
{
name: "forward scid",
buildHops: buildForwardSCIDHops,
},
{
name: "deliver",
buildHops: buildDeliverHops,
finalHopTLVs: []*lnwire.FinalHopTLV{
{
TLVType: customTLVType,
Value: []byte{1, 2, 3},
},
},
},
{
name: "forward concatenated path",
buildHops: buildConcatenatedPathHops,
},
{
name: "forward unknown peer",
buildHops: buildForwardUnknownPeerHops,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
h := newActorHarness(t)
result := tc.buildHops(t, h)
onionMsg, cipherTexts := BuildOnionMessage(
t, result.blindedPath, tc.finalHopTLVs,
)
req := &Request{msg: *onionMsg}
actorResult := h.actor.Receive(t.Context(), req)
require.True(t, actorResult.IsOk())
// Verify the update was dispatched.
select {
case update := <-h.dispatcher.updates:
require.Equal(
t, h.actor.peerPubKey,
update.Peer,
)
require.Equal(
t, onionMsg.OnionBlob,
update.OnionBlob,
)
expectedData := cipherTexts[0]
require.Equal(
t, expectedData,
update.EncryptedRecipientData,
)
for _, fht := range tc.finalHopTLVs {
tlvType := fht.TLVType
require.Equal(
t, fht.Value,
update.CustomRecords[uint64(
tlvType,
)],
)
}
default:
require.FailNow(t, "no update dispatched")
}
peeled := PeelOnionLayers(
t, result.privKeys, onionMsg,
)
require.Len(t, peeled, len(cipherTexts))
for i := range peeled {
require.Equal(
t, cipherTexts[i],
peeled[i].EncryptedData,
)
}
result.after()
})
}
}
// TestOnionPeerActorReceiveContextCanceled tests that OnionPeerActor.Receive
// returns an error when the context is canceled.
func TestOnionPeerActorReceiveContextCanceled(t *testing.T) {
t.Parallel()
h := newActorHarness(t)
ctx, cancel := context.WithCancel(t.Context())
cancel()
req := &Request{}
result := h.actor.Receive(ctx, req)
require.True(t, result.IsErr())
result.WhenErr(func(err error) {
require.ErrorIs(t, err, ErrActorShuttingDown)
})
}
// TestOnionPeerActorReceiveInvalidOnionBlob verifies that processing fails
// gracefully when provided with an invalid onion blob that cannot be decoded.
func TestOnionPeerActorReceiveInvalidOnionBlob(t *testing.T) {
t.Parallel()
h := newActorHarness(t)
onionMsg := lnwire.OnionMessage{
PathKey: h.nodeKey.PubKey(),
OnionBlob: []byte{1, 2, 3},
}
req := &Request{msg: onionMsg}
result := h.actor.Receive(t.Context(), req)
require.True(t, result.IsErr())
// Verify no update was dispatched.
select {
case <-h.dispatcher.updates:
require.FailNow(t, "unexpected update dispatched")
default:
}
}
// TestOnionPeerActorReceiveDispatcherError verifies that the actor returns an
// error when the update dispatcher fails.
func TestOnionPeerActorReceiveDispatcherError(t *testing.T) {
t.Parallel()
h := newActorHarness(t)
h.dispatcher.err = fmt.Errorf("dispatcher error")
rd := &record.BlindedRouteData{}
plain := EncodeBlindedRouteData(t, rd)
hops := []*sphinx.HopInfo{
{NodePub: h.nodeKey.PubKey(), PlainText: plain},
}
blindedPath := BuildBlindedPath(t, hops)
onionMsg, _ := BuildOnionMessage(t, blindedPath, nil)
req := &Request{msg: *onionMsg}
result := h.actor.Receive(t.Context(), req)
require.True(t, result.IsErr())
}

17
onionmessage/errors.go Normal file
View file

@ -0,0 +1,17 @@
package onionmessage
import "errors"
var (
// ErrActorShuttingDown is returned by the actor logic when its context
// is cancelled.
ErrActorShuttingDown = errors.New("actor shutting down")
// ErrNextNodeIdEmpty is returned when the next node ID is missing from
// the route data.
ErrNextNodeIdEmpty = errors.New("next node ID empty")
// ErrSCIDEmpty is returned when the short channel ID is missing from
// the route data.
ErrSCIDEmpty = errors.New("short channel ID empty")
)

196
onionmessage/hop.go Normal file
View file

@ -0,0 +1,196 @@
package onionmessage
import (
"bytes"
"context"
"github.com/btcsuite/btcd/btcec/v2"
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"
)
// forwardAction contains the information needed to forward an onion message to
// the next node as well as update any subscribers with the payload we received.
type forwardAction struct {
// nextNodeID is the public key of the peer to forward the message to
nextNodeID *btcec.PublicKey
// nextPathKey is the path key for the next hop, used for route
// blinding.
nextPathKey *btcec.PublicKey
// nextPacket is the serialized onion packet to send to the next hop.
nextPacket []byte
// payload contains the decoded payload for this hop, which may include
// custom records and routing information.
payload *lnwire.OnionMessagePayload
}
// deliverAction contains the information needed to deliver the payload to any
// subscribers. Since we only support forwarding onion messages, this is only
// needed in itest to verify correct handling and behavior.
type deliverAction struct {
// payload contains the decoded payload for this hop, which may include
// custom records and routing information.
payload *lnwire.OnionMessagePayload
}
type routingAction = fn.Either[forwardAction, deliverAction]
// NodeIDResolver defines an interface to resolve a node public key from a short
// channel ID.
type NodeIDResolver interface {
RemotePubFromSCID(ctx context.Context,
scid lnwire.ShortChannelID) (*btcec.PublicKey, error)
}
// processOnionMessage decodes and processes an onion message packet and its
// contents. It assumes route blinding is used, so it also decrypts encrypted
// recipient data, and derives the next path key. It returns a fn.Result type
// containing a routingAction, which contains all the information required to
// execute the next step in the routing process.
func processOnionMessage(ctx context.Context, router OnionRouter,
resolver NodeIDResolver,
msg *lnwire.OnionMessage) fn.Result[routingAction] {
var onionPkt sphinx.OnionPacket
err := onionPkt.Decode(bytes.NewReader(msg.OnionBlob))
if err != nil {
return fn.Err[routingAction](err)
}
// TODO(gijs): We should not use the magic value 10 here. It's the
// incomingCltv value and only has use for the replay protection that we
// don't need anyway.
processedPkt, err := router.ProcessOnionPacket(
&onionPkt, nil, 10, sphinx.WithBlindingPoint(msg.PathKey),
)
if err != nil {
return fn.Err[routingAction](err)
}
payload := lnwire.NewOnionMessagePayload()
_, err = payload.Decode(
bytes.NewReader(processedPkt.Payload.Payload),
)
if err != nil {
return fn.Err[routingAction](err)
}
// Create a shallow copy of the payload but deep copy the EncryptedData
// field, as the decryption below will overwrite the EncryptedData field
// in-place.
originalPayload := *payload
originalPayload.EncryptedData = bytes.Clone(payload.EncryptedData)
decrypted, err := router.DecryptBlindedHopData(
msg.PathKey, payload.EncryptedData,
)
if err != nil {
return fn.Err[routingAction](err)
}
routeData, err := record.DecodeBlindedRouteData(
bytes.NewReader(decrypted),
)
if err != nil {
return fn.Err[routingAction](err)
}
nextPathKey := deriveNextPathKey(router, msg.PathKey,
routeData.NextBlindingOverride)
action, err := createRoutingAction(
ctx, resolver, processedPkt, &originalPayload, routeData,
nextPathKey,
)
if err != nil {
return fn.Err[routingAction](err)
}
return fn.Ok(action)
}
// createRoutingAction creates the routing action based on whether we are
// forwarding or the receiver of the onion message.
func createRoutingAction(ctx context.Context, resolver NodeIDResolver,
packet *sphinx.ProcessedPacket, payload *lnwire.OnionMessagePayload,
routeData *record.BlindedRouteData,
nextPathKey *btcec.PublicKey) (routingAction, error) {
if isForwarding(packet) {
var nextNodeID *btcec.PublicKey
if routeData.NextNodeID.IsSome() {
n, err := routeData.NextNodeID.UnwrapOrErr(
ErrNextNodeIdEmpty,
)
if err != nil {
return routingAction{}, err
}
nextNodeID = n.Val
} else {
scid, err := routeData.ShortChannelID.UnwrapOrErr(
ErrSCIDEmpty,
)
if err != nil {
return routingAction{}, err
}
nextNodeID, err = resolver.RemotePubFromSCID(
ctx, scid.Val,
)
if err != nil {
return routingAction{}, err
}
}
buf := new(bytes.Buffer)
err := packet.NextPacket.Encode(buf)
if err != nil {
return routingAction{}, err
}
nextPacket := buf.Bytes()
return fn.NewLeft[forwardAction, deliverAction](forwardAction{
nextNodeID: nextNodeID,
nextPathKey: nextPathKey,
nextPacket: nextPacket,
payload: payload,
}), nil
}
return fn.NewRight[forwardAction](deliverAction{
payload: payload,
}), nil
}
// deriveNextPathKey derives the next path key using the router and current
// path key. If an override is provided, it is used instead.
func deriveNextPathKey(router OnionRouter, currentPathKey *btcec.PublicKey,
override tlv.OptionalRecordT[tlv.TlvType8,
*btcec.PublicKey]) *btcec.PublicKey {
// If an override is provided, use it.
return override.UnwrapOrFunc(func() tlv.RecordT[tlv.TlvType8,
*btcec.PublicKey] {
// Otherwise, derive the next path key using the router.
nextKey, err := router.NextEphemeral(currentPathKey)
if err != nil {
// If the derivation fails, log and return a zero key.
log.Warnf("Failed to derive next path key: %v", err)
return override.Zero()
}
return tlv.NewPrimitiveRecord[tlv.TlvType8](nextKey)
}).Val
}
// isForwarding checks if the packet is to be forwarded or delivered.
func isForwarding(packet *sphinx.ProcessedPacket) bool {
return packet.Action != sphinx.ExitNode
}

288
onionmessage/hop_test.go Normal file
View file

@ -0,0 +1,288 @@
package onionmessage
import (
"testing"
"github.com/btcsuite/btcd/btcec/v2"
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"
"github.com/stretchr/testify/require"
)
// processOnionMessageTest defines the test parameters for testing
// processOnionMessage with different routing scenarios.
type processOnionMessageTest struct {
name string
hopsToBlind []*sphinx.HopInfo
isDeliver bool
expectedNextNode *btcec.PublicKey
expectedOverride *btcec.PublicKey
}
// TestProcessOnionMessage tests the processOnionMessage function with various
// forwarding and delivery scenarios.
func TestProcessOnionMessage(t *testing.T) {
// Helper to generate keys.
genKey := func() *btcec.PrivateKey {
k, err := btcec.NewPrivateKey()
require.NoError(t, err)
return k
}
// Setup the local node (router).
nodeKeyA := genKey()
pubKeyA := nodeKeyA.PubKey()
router := sphinx.NewRouter(
&sphinx.PrivKeyECDH{PrivKey: nodeKeyA},
sphinx.NewNoOpReplayLog(),
)
require.NoError(t, router.Start())
defer router.Stop()
resolver := newMockNodeIDResolver()
// Pre-generate keys for test cases.
nodeKeyB := genKey()
pubKeyB := nodeKeyB.PubKey()
overrideKey := genKey()
pubKeyOverride := overrideKey.PubKey()
// Helper to encode route data.
encodeData := func(data *record.BlindedRouteData) []byte {
b, err := record.EncodeBlindedRouteData(data)
require.NoError(t, err)
return b
}
// Case 1 Data: Forward Action Success.
nextNodeByPubKey := fn.NewLeft[*btcec.PublicKey, lnwire.ShortChannelID](
pubKeyB,
)
rd1A := record.NewNonFinalBlindedRouteDataOnionMessage(
nextNodeByPubKey, nil, nil,
)
rd1B := &record.BlindedRouteData{}
hops1 := []*sphinx.HopInfo{
{NodePub: pubKeyA, PlainText: encodeData(rd1A)},
{NodePub: pubKeyB, PlainText: encodeData(rd1B)},
}
// Case 2 Data: Forward Action Path Key Override Success.
nextNodeWithOverride := fn.NewLeft[
*btcec.PublicKey, lnwire.ShortChannelID,
](pubKeyB)
rd2A := record.NewNonFinalBlindedRouteDataOnionMessage(
nextNodeWithOverride, pubKeyOverride, nil,
)
rd2B := &record.BlindedRouteData{}
hops2 := []*sphinx.HopInfo{
{NodePub: pubKeyA, PlainText: encodeData(rd2A)},
{NodePub: pubKeyB, PlainText: encodeData(rd2B)},
}
// Case 3 Data: Forward Action Success with SCID resolution.
scid := lnwire.NewShortChanIDFromInt(12345)
resolver.addPeer(scid, pubKeyB)
nextNodeBySCID := fn.NewRight[*btcec.PublicKey](
scid,
)
rd3A := record.NewNonFinalBlindedRouteDataOnionMessage(
nextNodeBySCID, nil, nil,
)
rd3B := &record.BlindedRouteData{}
hops3 := []*sphinx.HopInfo{
{NodePub: pubKeyA, PlainText: encodeData(rd3A)},
{NodePub: pubKeyB, PlainText: encodeData(rd3B)},
}
// Case 4 Data: Deliver Action Success.
rd4 := &record.BlindedRouteData{}
hops4 := []*sphinx.HopInfo{
{NodePub: pubKeyA, PlainText: encodeData(rd4)},
}
tests := []processOnionMessageTest{
{
name: "Forward Action Success",
hopsToBlind: hops1,
isDeliver: false,
expectedNextNode: pubKeyB,
expectedOverride: nil, // No path key override.
},
{
name: "Forward Action Path Key Override " +
"Success",
hopsToBlind: hops2,
isDeliver: false,
expectedNextNode: pubKeyB,
expectedOverride: pubKeyOverride,
},
{
name: "Forward Action Success with SCID " +
"Resolution",
hopsToBlind: hops3,
isDeliver: false,
expectedNextNode: pubKeyB,
expectedOverride: nil,
},
{
name: "Deliver Action Success",
hopsToBlind: hops4,
isDeliver: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
testProcessOnionMessageCase(t, router, resolver, tc)
})
}
}
// testProcessOnionMessageCase is a helper that executes a single test case for
// processOnionMessage, building the blinded path and verifying the result.
func testProcessOnionMessageCase(t *testing.T, router OnionRouter,
resolver NodeIDResolver, tc processOnionMessageTest) {
blindedPath := BuildBlindedPath(t, tc.hopsToBlind)
msg, expectedCipherTexts := BuildOnionMessage(
t, blindedPath, nil,
)
// Process the message.
result := processOnionMessage(t.Context(), router, resolver, msg)
require.True(t, result.IsOk())
// Verify result.
if tc.isDeliver {
result.WhenOk(func(action routingAction) {
// Should be deliverAction.
require.True(t, action.IsRight())
action.WhenRight(func(dlvrAction deliverAction) {
require.Equal(
t,
expectedCipherTexts[0],
dlvrAction.payload.EncryptedData,
)
})
})
} else {
result.WhenOk(func(action routingAction) {
// Should be forwardAction.
require.True(t, action.IsLeft())
action.WhenLeft(func(fwdAction forwardAction) {
require.Equal(
t, tc.expectedNextNode,
fwdAction.nextNodeID,
)
if tc.expectedOverride != nil {
require.Equal(
t, tc.expectedOverride,
fwdAction.nextPathKey,
)
} else {
require.NotNil(t, fwdAction.nextPathKey)
}
require.NotEmpty(t, fwdAction.nextPacket)
require.Equal(
t,
expectedCipherTexts[0],
fwdAction.payload.EncryptedData,
)
})
})
}
}
// TestIsForwarding tests the isForwarding function.
func TestIsForwarding(t *testing.T) {
t.Parallel()
tests := []struct {
name string
packet *sphinx.ProcessedPacket
expected bool
}{
{
name: "forwarding",
packet: &sphinx.ProcessedPacket{
Action: sphinx.MoreHops,
},
expected: true,
},
{
name: "delivery",
packet: &sphinx.ProcessedPacket{
Action: sphinx.ExitNode,
},
expected: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
result := isForwarding(test.packet)
require.Equal(t, test.expected, result)
})
}
}
// TestDeriveNextPathKey tests the deriveNextPathKey function.
func TestDeriveNextPathKey(t *testing.T) {
t.Parallel()
// create a private key for the router.
privKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
// create a path key.
sessionKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
pathKey := sessionKey.PubKey()
// Create a router. We don't need a replay log for this test as
// NextEphemeral doesn't use it.
router := sphinx.NewRouter(&sphinx.PrivKeyECDH{PrivKey: privKey}, nil)
t.Run("override present", func(t *testing.T) {
overrideKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
override := tlv.NewPrimitiveRecord[tlv.TlvType8](
overrideKey.PubKey(),
)
optOverride := tlv.SomeRecordT(override)
// Router can be nil as it shouldn't be used.
result := deriveNextPathKey(nil, pathKey, optOverride)
require.Equal(t, overrideKey.PubKey(), result)
})
t.Run("derive success", func(t *testing.T) {
override := tlv.OptionalRecordT[tlv.TlvType8,
*btcec.PublicKey]{}
result := deriveNextPathKey(router, pathKey, override)
require.NotNil(t, result)
// Verify it matches manual derivation.
expected, err := router.NextEphemeral(pathKey)
require.NoError(t, err)
require.Equal(t, expected, result)
})
// It's currently impossible to test derivation failure as there is no
// way to make the key derivation fail with an error. You can only make
// it panick by passing in a nil path key. This is due to how
// PrivKeyECDH.ECDH is implemented in the keychain package.
}

View file

@ -0,0 +1,41 @@
package onionmessage
import (
"github.com/btcsuite/btcd/btcec/v2"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/lnwire"
)
// OnionRouter wraps the sphinx router operations needed for onion message
// processing.
type OnionRouter interface {
// ProcessOnionPacket processes an onion packet and returns the
// processed result.
ProcessOnionPacket(pkt *sphinx.OnionPacket, assocData []byte,
incomingCltv uint32,
opts ...sphinx.ProcessOnionOpt) (*sphinx.ProcessedPacket, error)
// DecryptBlindedHopData decrypts the encrypted hop data using the
// given path key.
DecryptBlindedHopData(pathKey *btcec.PublicKey,
encData []byte) ([]byte, error)
// NextEphemeral derives the next ephemeral key from the current path
// key.
NextEphemeral(
currentPathKey *btcec.PublicKey) (*btcec.PublicKey, error)
}
// OnionMessageUpdateDispatcher dispatches onion message updates to
// subscribers.
type OnionMessageUpdateDispatcher interface {
// SendUpdate sends an onion message update to all subscribers.
SendUpdate(update any) error
}
// PeerMessageSender sends onion messages to peers identified by public key.
type PeerMessageSender interface {
// SendToPeer sends an onion message to the peer identified by the
// given compressed public key.
SendToPeer(pubKey [33]byte, msg *lnwire.OnionMessage) error
}

View file

@ -1,15 +1,8 @@
package onionmessage
import (
"context"
"encoding/hex"
"log/slog"
"github.com/btcsuite/btclog/v2"
"github.com/lightningnetwork/lnd/lnutils"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/msgmux"
"github.com/lightningnetwork/lnd/subscribe"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/record"
)
// OnionMessageUpdate is onion message update dispatched to any potential
@ -27,75 +20,17 @@ type OnionMessageUpdate struct {
// manner as onions used to route HTLCs, with the exception that it uses
// blinded routes by default.
OnionBlob []byte
}
// OnionEndpoint handles incoming onion messages.
type OnionEndpoint struct {
// subscribe.Server is used for subscriptions to onion messages.
onionMessageServer *subscribe.Server
}
// A compile-time check to ensure OnionEndpoint implements the Endpoint
// interface.
var _ msgmux.Endpoint = (*OnionEndpoint)(nil)
// NewOnionEndpoint creates a new OnionEndpoint.
func NewOnionEndpoint(messageServer *subscribe.Server) *OnionEndpoint {
return &OnionEndpoint{
onionMessageServer: messageServer,
}
}
// Name returns the unique name of the endpoint.
func (o *OnionEndpoint) Name() string {
return "OnionMessageHandler"
}
// CanHandle checks if the endpoint can handle the incoming message.
// It returns true if the message is an lnwire.OnionMessage.
func (o *OnionEndpoint) CanHandle(msg msgmux.PeerMsg) bool {
_, ok := msg.Message.(*lnwire.OnionMessage)
return ok
}
// SendMessage processes the incoming onion message.
// It returns true if the message was successfully processed.
func (o *OnionEndpoint) SendMessage(ctx context.Context,
msg msgmux.PeerMsg) bool {
onionMsg, ok := msg.Message.(*lnwire.OnionMessage)
if !ok {
return false
}
peer := msg.PeerPub.SerializeCompressed()
logCtx := btclog.WithCtx(ctx,
slog.String("peer", hex.EncodeToString(peer)),
lnutils.LogPubKey("path_key", onionMsg.PathKey),
)
log.DebugS(logCtx, "OnionEndpoint received OnionMessage",
btclog.HexN("onion_blob", onionMsg.OnionBlob, 10),
slog.Int("blob_length", len(onionMsg.OnionBlob)))
var peerArr [33]byte
copy(peerArr[:], peer)
// Convert path key []byte to [33]byte.
pathKey := onionMsg.PathKey.SerializeCompressed()
var pathKeyArr [33]byte
copy(pathKeyArr[:], pathKey)
err := o.onionMessageServer.SendUpdate(&OnionMessageUpdate{
Peer: peerArr,
PathKey: pathKeyArr,
OnionBlob: onionMsg.OnionBlob,
})
if err != nil {
log.ErrorS(logCtx, "Failed to send onion message update", err)
return false
}
return true
// CustomRecords contains any custom TLV records included in the
// payload.
CustomRecords record.CustomSet
// ReplyPath contains the reply path information for the onion message.
ReplyPath *sphinx.BlindedPath
// EncryptedRecipientData contains the encrypted recipient data for the
// onion message, created by the creator of the blinded route. This is
// the receiver for the last leg of the route, and the sender for the
// first leg up to the introduction point.
EncryptedRecipientData []byte
}

121
onionmessage/resolver.go Normal file
View file

@ -0,0 +1,121 @@
package onionmessage
import (
"context"
"encoding/hex"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/lightninglabs/neutrino/cache/lru"
graphdb "github.com/lightningnetwork/lnd/graph/db"
"github.com/lightningnetwork/lnd/lnwire"
)
const (
// defaultSCIDCacheSize is the default number of SCID to pubkey mappings
// to cache. This is relatively small since onion message forwarding via
// SCID is expected to be infrequent compared to forwarding via explicit
// node ID.
defaultSCIDCacheSize = 1000
)
// cachedPubKey is a wrapper around a compressed public key that implements the
// cache.Value interface required by the LRU cache.
type cachedPubKey struct {
pubKeyBytes [33]byte
}
// Size returns the "size" of an entry. We return 1 as we just want to limit
// the total number of entries rather than do accurate size accounting.
func (c *cachedPubKey) Size() (uint64, error) {
return 1, nil
}
// GraphNodeResolver resolves node public keys from short channel IDs using the
// channel graph. It maintains an LRU cache to avoid repeated database lookups
// for frequently used SCIDs.
type GraphNodeResolver struct {
graph *graphdb.ChannelGraph
ourPub *btcec.PublicKey
// scidCache is an LRU cache mapping SCID (as uint64) to the remote
// node's compressed public key bytes.
scidCache *lru.Cache[uint64, *cachedPubKey]
}
// NewGraphNodeResolver creates a new GraphNodeResolver with the given channel
// graph and our node's public key. It initializes an LRU cache for SCID
// lookups.
func NewGraphNodeResolver(graph *graphdb.ChannelGraph,
ourPub *btcec.PublicKey) *GraphNodeResolver {
return &GraphNodeResolver{
graph: graph,
ourPub: ourPub,
scidCache: lru.NewCache[uint64, *cachedPubKey](
defaultSCIDCacheSize,
),
}
}
// RemotePubFromSCID resolves a node public key from a short channel ID.
func (r *GraphNodeResolver) RemotePubFromSCID(ctx context.Context,
scid lnwire.ShortChannelID) (*btcec.PublicKey, error) {
scidInt := scid.ToUint64()
// Check the cache first.
if cached, err := r.scidCache.Get(scidInt); err == nil {
pubKey, parseErr := btcec.ParsePubKey(cached.pubKeyBytes[:])
if parseErr == nil {
log.Tracef("Resolved SCID %v from cache to node %s",
scid,
hex.EncodeToString(cached.pubKeyBytes[:]))
return pubKey, nil
}
// Cache contained invalid data, fall through to DB lookup.
log.Debugf("Invalid cached pubkey for SCID %v: %v",
scid, parseErr)
}
log.Tracef("Resolving node public key for SCID %v from graph", scid)
edge, _, _, err := r.graph.FetchChannelEdgesByID(ctx, scid.ToUint64())
if err != nil {
log.Debugf("Failed to fetch channel edges for SCID %v: %v",
scid, err)
return nil, err
}
otherNodeKeyBytes, err := edge.OtherNodeKeyBytes(
r.ourPub.SerializeCompressed(),
)
if err != nil {
log.Debugf("Failed to get other node key for SCID %v: %v",
scid, err)
return nil, err
}
pubKey, err := btcec.ParsePubKey(otherNodeKeyBytes[:])
if err != nil {
log.Debugf("Failed to parse public key for SCID %v: %v",
scid, err)
return nil, err
}
// Cache the result for future lookups. We ignore the return values as
// caching is best-effort and a failure just means the next lookup will
// hit the database again.
_, _ = r.scidCache.Put(scidInt, &cachedPubKey{
pubKeyBytes: otherNodeKeyBytes,
})
log.Tracef("Resolved SCID %v to node %s", scid,
hex.EncodeToString(pubKey.SerializeCompressed()))
return pubKey, nil
}

View file

@ -0,0 +1,40 @@
package onionmessage
import (
"testing"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/stretchr/testify/require"
)
func TestMockNodeIDResolverRemotePubFromSCID(t *testing.T) {
t.Parallel()
t.Run("success", func(t *testing.T) {
t.Parallel()
resolver := newMockNodeIDResolver()
priv, err := btcec.NewPrivateKey()
require.NoError(t, err)
pubKey := priv.PubKey()
scid := lnwire.NewShortChanIDFromInt(1)
resolver.addPeer(scid, pubKey)
got, err := resolver.RemotePubFromSCID(t.Context(), scid)
require.NoError(t, err)
require.Equal(t, pubKey, got)
})
t.Run("unknown scid", func(t *testing.T) {
t.Parallel()
resolver := newMockNodeIDResolver()
scid := lnwire.NewShortChanIDFromInt(2)
got, err := resolver.RemotePubFromSCID(t.Context(), scid)
require.Error(t, err)
require.Nil(t, got)
})
}

249
onionmessage/test_utils.go Normal file
View file

@ -0,0 +1,249 @@
package onionmessage
import (
"bytes"
"context"
"fmt"
"testing"
"github.com/btcsuite/btcd/btcec/v2"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/record"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/lightningnetwork/lnd/tlv"
"github.com/stretchr/testify/require"
)
// mockNodeIDResolver implements NodeIDResolver for tests.
type mockNodeIDResolver struct {
peers map[lnwire.ShortChannelID]*btcec.PublicKey
}
// addPeer registers a single SCID to pubkey mapping for tests.
func (m *mockNodeIDResolver) addPeer(scid lnwire.ShortChannelID,
pubKey *btcec.PublicKey) {
m.peers[scid] = pubKey
}
// newMockNodeIDResolver creates a new instance of mockNodeIDResolver.
func newMockNodeIDResolver() *mockNodeIDResolver {
return &mockNodeIDResolver{
peers: make(map[lnwire.ShortChannelID]*btcec.PublicKey),
}
}
// RemotePubFromSCID resolves a node public key from a short channel ID.
func (m *mockNodeIDResolver) RemotePubFromSCID(_ context.Context,
scid lnwire.ShortChannelID) (*btcec.PublicKey, error) {
if pk, ok := m.peers[scid]; ok {
return pk, nil
}
return nil, fmt.Errorf("unknown scid: %v", scid)
}
// EncodeBlindedRouteData encodes BlindedRouteData to bytes for use in test
// hop payloads.
func EncodeBlindedRouteData(t *testing.T,
data *record.BlindedRouteData) []byte {
t.Helper()
buf, err := record.EncodeBlindedRouteData(data)
require.NoError(t, err)
return buf
}
// BuildBlindedPath creates a BlindedPathInfo from a list of HopInfo. This is a
// test helper that wraps sphinx.BuildBlindedPath with a fresh session key.
func BuildBlindedPath(t *testing.T,
hops []*sphinx.HopInfo) *sphinx.BlindedPathInfo {
t.Helper()
sessionKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
blindedPath, err := sphinx.BuildBlindedPath(sessionKey, hops)
require.NoError(t, err)
return blindedPath
}
// ConcatBlindedPaths concatenates two blinded paths. The sender's path points
// TO the introduction node (with NextBlindingOverride), and the receiver's
// path starts AT the introduction node. The concatenated path includes all
// hops from both paths - the sender's last hop instructs forwarding to the
// intro node, and all receiver hops follow.
func ConcatBlindedPaths(t *testing.T, senderPath,
receiverPath *sphinx.BlindedPathInfo) *sphinx.BlindedPathInfo {
t.Helper()
// The resulting path uses the sender's session key and introduction
// point but concatenates all blinded hops.
concatenated := &sphinx.BlindedPath{
IntroductionPoint: senderPath.Path.IntroductionPoint,
BlindingPoint: senderPath.Path.BlindingPoint,
BlindedHops: append(
senderPath.Path.BlindedHops,
receiverPath.Path.BlindedHops...,
),
}
return &sphinx.BlindedPathInfo{
Path: concatenated,
SessionKey: senderPath.SessionKey,
LastEphemeralKey: receiverPath.LastEphemeralKey,
}
}
// BuildOnionMessage builds an onion message from a BlindedPathInfo and returns
// the message along with the ciphertexts for each blinded hop (in hop order).
// If finalPayloads is nil or empty, no final hop payload data is included.
func BuildOnionMessage(t *testing.T, blindedPath *sphinx.BlindedPathInfo,
finalHopTLVs []*lnwire.FinalHopTLV) (*lnwire.OnionMessage,
[][]byte) {
t.Helper()
// Convert the blinded path to a sphinx path and add final payloads.
sphinxPath, err := route.OnionMessageBlindedPathToSphinxPath(
blindedPath.Path, nil, finalHopTLVs,
)
require.NoError(t, err)
onionSessionKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
// Create an onion packet with no associated data.
onionPkt, err := sphinx.NewOnionPacket(
sphinxPath, onionSessionKey, nil,
sphinx.DeterministicPacketFiller,
sphinx.WithMaxPayloadSize(sphinx.MaxRoutingPayloadSize),
)
require.NoError(t, err)
// Encode the onion message packet.
var buf bytes.Buffer
require.NoError(t, onionPkt.Encode(&buf))
onionMsg := &lnwire.OnionMessage{
PathKey: blindedPath.SessionKey.PubKey(),
OnionBlob: buf.Bytes(),
}
var ctexts [][]byte
for _, bh := range blindedPath.Path.BlindedHops {
ctexts = append(ctexts, bh.CipherText)
}
return onionMsg, ctexts
}
// PeeledHop captures decrypted state for a single hop when peeling an onion.
type PeeledHop struct {
EncryptedData []byte
Payload *lnwire.OnionMessagePayload
IsFinal bool
}
// PeelOnionLayers sequentially processes an onion message, creating a fresh
// router for each hop using the provided private keys (one per hop), returning
// the encrypted data and decoded payload for each hop until the final hop.
func PeelOnionLayers(t *testing.T, privKeys []*btcec.PrivateKey,
msg *lnwire.OnionMessage) []PeeledHop {
t.Helper()
var onionPkt sphinx.OnionPacket
require.NoError(t, onionPkt.Decode(bytes.NewReader(msg.OnionBlob)))
currentPathKey := msg.PathKey
var hops []PeeledHop
for i := 0; ; i++ {
require.Less(t, i, len(privKeys), "more hops than privKeys")
router := sphinx.NewRouter(
&sphinx.PrivKeyECDH{PrivKey: privKeys[i]},
sphinx.NewNoOpReplayLog(),
)
require.NoError(t, router.Start())
processedPkt, err := router.ProcessOnionPacket(
&onionPkt, nil, 10,
sphinx.WithBlindingPoint(currentPathKey),
)
require.NoError(t, err)
payload := lnwire.NewOnionMessagePayload()
_, err = payload.Decode(
bytes.NewReader(processedPkt.Payload.Payload),
)
require.NoError(t, err)
origPayload := *payload
origPayload.EncryptedData = bytes.Clone(payload.EncryptedData)
isFinal := processedPkt.Action == sphinx.ExitNode
hops = append(hops, PeeledHop{
EncryptedData: origPayload.EncryptedData,
Payload: &origPayload,
IsFinal: isFinal,
})
if isFinal {
router.Stop()
break
}
decrypted, err := router.DecryptBlindedHopData(
currentPathKey, payload.EncryptedData,
)
require.NoError(t, err)
routeData, err := record.DecodeBlindedRouteData(
bytes.NewReader(decrypted),
)
require.NoError(t, err)
nextPathKey := deriveNextPathKeyForTest(
router, currentPathKey, routeData.NextBlindingOverride,
)
require.NotNil(t, nextPathKey)
router.Stop()
onionPkt = *processedPkt.NextPacket
currentPathKey = nextPathKey
}
return hops
}
// deriveNextPathKeyForTest derives the next path key using the router and
// current path key. If an override is provided, it is used instead.
func deriveNextPathKeyForTest(router *sphinx.Router,
currentPathKey *btcec.PublicKey,
override tlv.OptionalRecordT[tlv.TlvType8,
*btcec.PublicKey]) *btcec.PublicKey {
// If an override is provided, use it.
return override.UnwrapOrFunc(func() tlv.RecordT[tlv.TlvType8,
*btcec.PublicKey] {
// Otherwise, derive the next path key using the router.
nextKey, err := router.NextEphemeral(currentPathKey)
if err != nil {
// If the derivation fails, return a zero key.
return override.Zero()
}
return tlv.NewPrimitiveRecord[tlv.TlvType8](nextKey)
}).Val
}

View file

@ -19,6 +19,7 @@ import (
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btclog/v2"
"github.com/lightningnetwork/lnd/actor"
"github.com/lightningnetwork/lnd/aliasmgr"
"github.com/lightningnetwork/lnd/brontide"
"github.com/lightningnetwork/lnd/buffer"
@ -302,9 +303,16 @@ type Config struct {
// the Brontide.
RoutingPolicy models.ForwardingPolicy
// Sphinx is used when setting up ChannelLinks so they can decode sphinx
// onion blobs.
Sphinx *hop.OnionProcessor
// SphinxPayment is used when setting up ChannelLinks so they can decode
// sphinx onion blobs.
SphinxPayment *hop.OnionProcessor
// SpawnOnionActor is a factory function that spawns a per-peer onion
// message actor. If nil, onion messaging is disabled.
SpawnOnionActor onionmessage.OnionActorFactory
// ActorSystem is the actor system tasked with managing actors.
ActorSystem *actor.ActorSystem
// WitnessBeacon is used when setting up ChannelLinks so they can add any
// preimages that they learn.
@ -472,10 +480,6 @@ type Config struct {
// related wire messages.
AuxChannelNegotiator fn.Option[lnwallet.AuxChannelNegotiator]
// OnionMessageServer is an instance of a message server that dispatches
// onion messages to subscribers.
OnionMessageServer *subscribe.Server
// ShouldFwdExpAccountability is a closure that indicates whether
// experimental accountability signals should be set.
ShouldFwdExpAccountability func() bool
@ -540,6 +544,11 @@ type Brontide struct {
// this heuristic is good enough for your use case.
isTorConnection bool
// onionActorRef holds the reference to the onion peer actor spawned
// for this peer connection. The actor handles all incoming onion
// message processing for this peer.
onionActorRef fn.Option[onionmessage.OnionPeerActorRef]
pingManager *PingManager
// lastPingPayload stores an unsafe pointer wrapped as an atomic
@ -911,19 +920,25 @@ func (p *Brontide) Start() error {
return fmt.Errorf("unable to load channels: %w", err)
}
onionMessageEndpoint := onionmessage.NewOnionEndpoint(
p.cfg.OnionMessageServer,
)
// If the remote peer supports onion messages and we have a factory
// configured, spawn the onion peer actor for this connection. The
// actor handles the full processing pipeline for incoming onion
// messages from this peer.
if p.remoteFeatures.HasFeature(lnwire.OnionMessagesOptional) &&
p.cfg.SpawnOnionActor != nil {
// We register the onion message endpoint with the message router.
err = fn.MapOptionZ(p.msgRouter, func(r msgmux.Router) error {
_ = r.UnregisterEndpoint(onionMessageEndpoint.Name())
p.log.Infof("Remote peer supports onion messages, " +
"spawning onion message actor")
return r.RegisterEndpoint(onionMessageEndpoint)
})
if err != nil {
return fmt.Errorf("unable to register endpoint for onion "+
"messaging: %w", err)
ref, spawnErr := p.cfg.SpawnOnionActor(
p.cfg.ActorSystem, p.PubKey(),
)
if spawnErr != nil {
return fmt.Errorf("unable to spawn onion peer "+
"actor: %w", spawnErr)
}
p.onionActorRef = fn.Some(ref)
}
p.startTime = time.Now()
@ -1457,8 +1472,8 @@ func (p *Brontide) addLink(chanPoint *wire.OutPoint,
//nolint:ll
linkCfg := htlcswitch.ChannelLinkConfig{
Peer: p,
DecodeHopIterators: p.cfg.Sphinx.DecodeHopIterators,
ExtractErrorEncrypter: p.cfg.Sphinx.ExtractErrorEncrypter,
DecodeHopIterators: p.cfg.SphinxPayment.DecodeHopIterators,
ExtractErrorEncrypter: p.cfg.SphinxPayment.ExtractErrorEncrypter,
FetchLastChannelUpdate: p.cfg.FetchLastChanUpdate,
HodlMask: p.cfg.Hodl.Mask(),
Registry: p.cfg.Invoices,
@ -1688,6 +1703,9 @@ func (p *Brontide) Disconnect(reason error) {
// Stop PingManager before closing TCP connection.
p.pingManager.Stop()
// Stop the onion peer actor if one was spawned.
p.StopOnionActorIfExists()
// Ensure that the TCP connection is properly closed before continuing.
p.cfg.Conn.Close()
@ -1707,6 +1725,18 @@ func (p *Brontide) String() string {
return fmt.Sprintf("%x@%s", p.cfg.PubKeyBytes, p.cfg.Conn.RemoteAddr())
}
// StopOnionActorIfExists stops the onion peer actor if one was spawned for
// this peer. This is idempotent and safe to call multiple times.
func (p *Brontide) StopOnionActorIfExists() {
p.onionActorRef.WhenSome(
func(ref onionmessage.OnionPeerActorRef) {
onionmessage.StopOnionActor(
p.cfg.ActorSystem, p.PubKey(), ref,
)
},
)
}
// readNextMessage reads, and returns the next message on the wire along with
// any additional raw payload.
func (p *Brontide) readNextMessage() (lnwire.Message, error) {
@ -2272,6 +2302,19 @@ out:
discStream.AddMsg(msg)
case *lnwire.OnionMessage:
p.onionActorRef.WhenSome(
func(ref onionmessage.OnionPeerActorRef) {
// TODO(elle): thread contexts through
// the peer system properly so that a
// parent context can be passed in here.
ctx := context.TODO()
req := onionmessage.NewRequest(*msg)
ref.Tell(ctx, req)
},
)
case *lnwire.Custom:
err := p.handleCustomMessage(msg)
if err != nil {
@ -2587,6 +2630,15 @@ func messageSummary(msg lnwire.Message) string {
time.Unix(int64(msg.FirstTimestamp), 0),
msg.TimestampRange)
case *lnwire.OnionMessage:
var pathKey []byte
if msg.PathKey != nil {
pathKey = msg.PathKey.SerializeCompressed()
}
return fmt.Sprintf("path_key=%x, onion_len=%v", pathKey,
len(msg.OnionBlob))
case *lnwire.Stfu:
return fmt.Sprintf("chan_id=%v, initiator=%v", msg.ChanID,
msg.Initiator)

View file

@ -694,11 +694,19 @@ func createTestPeer(t *testing.T) *peerTestCtx {
var pubKey [33]byte
copy(pubKey[:], aliceKeyPub.SerializeCompressed())
// We have to have a valid server key for brontide to start up properly.
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
var serverKeyArr [33]byte
copy(serverKeyArr[:], serverKey.PubKey().SerializeCompressed())
estimator := chainfee.NewStaticEstimator(12500, 0)
cfg := &Config{
Addr: cfgAddr,
PubKeyBytes: pubKey,
ServerPubKey: serverKeyArr,
ErrorBuffer: errBuffer,
ChainIO: chainIO,
Switch: mockSwitch,

View file

@ -6,6 +6,7 @@ import (
"io"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/tlv"
)
@ -90,6 +91,47 @@ func NewNonFinalBlindedRouteData(chanID lnwire.ShortChannelID,
return info
}
// NewNonFinalBlindedRouteData creates the data that's provided for hops within
// a blinded route.
func NewNonFinalBlindedRouteDataOnionMessage(
nextNode fn.Either[*btcec.PublicKey, lnwire.ShortChannelID],
blindingOverride *btcec.PublicKey,
features *lnwire.FeatureVector) *BlindedRouteData {
info := fn.ElimEither(
nextNode,
func(nextNodeID *btcec.PublicKey) *BlindedRouteData {
return &BlindedRouteData{
NextNodeID: tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType4](
nextNodeID,
),
),
}
},
func(chanID lnwire.ShortChannelID) *BlindedRouteData {
return &BlindedRouteData{
ShortChannelID: tlv.SomeRecordT(
tlv.NewRecordT[tlv.TlvType2](chanID),
),
}
},
)
if blindingOverride != nil {
info.NextBlindingOverride = tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType8](blindingOverride))
}
if features != nil {
info.Features = tlv.SomeRecordT(
tlv.NewRecordT[tlv.TlvType14](*features),
)
}
return info
}
// NewFinalHopBlindedRouteData creates the data that's provided for the final
// hop in a blinded route.
func NewFinalHopBlindedRouteData(constraints *PaymentConstraints,

View file

@ -6,6 +6,8 @@ import (
)
const (
// Onion Routing Packet types.
// AmtOnionType is the type used in the onion to reference the amount to
// send to the next hop.
AmtOnionType tlv.Type = 2
@ -33,6 +35,28 @@ const (
// 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
// Onion Message Packet types.
// ReplyPathType is the type used in the onion message to indicate the
// blinded path to be used for replies.
ReplyPathType tlv.Type = 2
// EncryptedDataTLVType is the type used in the onion message to
// include encrypted data in the onion for use in blinded paths.
EncryptedDataTLVType tlv.Type = 4
// InvoiceRequestNamespaceType is the type used in the onion message to
// include invoice requests.
InvoiceRequestNamespaceType tlv.Type = 64
// InvoiceNamespaceType is the type used in the onion message to include
// invoices.
InvoiceNamespaceType tlv.Type = 66
// InvoiceErrorNamespaceType is the type used in the onion message to
// include invoice errors.
InvoiceErrorNamespaceType tlv.Type = 68
)
// NewAmtToFwdRecord creates a tlv.Record that encodes the amount_to_forward
@ -69,6 +93,36 @@ func NewEncryptedDataRecord(data *[]byte) tlv.Record {
return tlv.MakePrimitiveRecord(EncryptedDataOnionType, data)
}
// NewEncryptedRecipientDataRecord creates a tlv.Record that encodes the
// encrypted_data (type 4) record for an onion message payload.
func NewEncryptedRecipientDataRecord(data *[]byte) tlv.Record {
return tlv.MakePrimitiveRecord(EncryptedDataTLVType, data)
}
// NewReplyPathRecord creates a tlv.Record that encodes the reply_path (type 2)
// record for an onion message payload.
func NewReplyPathRecord(data *[]byte) tlv.Record {
return tlv.MakePrimitiveRecord(ReplyPathType, data)
}
// NewInvoiceRequestRecord creates a tlv.Record that encodes the
// invoice_request (type 64) record for an onion message payload.
func NewInvoiceRequestRecord(data *[]byte) tlv.Record {
return tlv.MakePrimitiveRecord(InvoiceRequestNamespaceType, data)
}
// NewInvoiceRecord creates a tlv.Record that encodes the
// invoice (type 66) record for an onion message payload.
func NewInvoiceRecord(data *[]byte) tlv.Record {
return tlv.MakePrimitiveRecord(InvoiceNamespaceType, data)
}
// NewInvoiceErrorRecord creates a tlv.Record that encodes the
// invoice_error (type 68) record for an onion message payload.
func NewInvoiceErrorRecord(data *[]byte) tlv.Record {
return tlv.MakePrimitiveRecord(InvoiceErrorNamespaceType, 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 {

View file

@ -978,7 +978,7 @@ func findPath(g *graphParams, r *RestrictParams, cfg *PathFindingConfig,
routingInfoSize := toNodeDist.routingInfoSize + payloadSize
// Skip paths that would exceed the maximum routing info size.
if routingInfoSize > sphinx.MaxPayloadSize {
if routingInfoSize > sphinx.MaxRoutingPayloadSize {
return
}

View file

@ -0,0 +1,87 @@
package route
import (
"fmt"
"github.com/btcsuite/btcd/btcec/v2"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/lnwire"
)
// OnionMessageBlindedPathToSphinxPath converts a complete blinded path intended
// for sending an onion message to a PaymentPath that contains the per-hop
// 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) (
*sphinx.PaymentPath, error) {
var path sphinx.PaymentPath
// We can only construct a route if there are hops provided.
if len(blindedPath.BlindedHops) == 0 {
return nil, ErrNoRouteHopsProvided
}
// Check maximum route length. We keep the maximum the same as
// sphinx.NumMaxHops for simplicity. In theory the maximum for onion
// messages could be higher, namely 481. See:
// https://delvingbitcoin.org/t/onion-messaging-dos-threat-mitigations
if len(blindedPath.BlindedHops) > sphinx.NumMaxHops {
return nil, ErrMaxRouteHopsExceeded
}
// For each hop encoded within the route, we'll convert the hop struct
// to an OnionHop with matching per-hop payload within the path as used
// by the sphinx package.
for i, hop := range blindedPath.BlindedHops {
// Create an onionMessagePayload with the encrypted data for
// this hop.
onionMessagePayload := &lnwire.OnionMessagePayload{
EncryptedData: hop.CipherText,
}
// If we're on the final hop include the tlvs intended for the
// final hop and the reply path (if provided).
finalHop := i == len(blindedPath.BlindedHops)-1
if finalHop {
onionMessagePayload.FinalHopTLVs = finalHopTLVs
onionMessagePayload.ReplyPath = replyPath
}
// create a sphinx hop for this blinded hop.
hop, err := createSphinxHop(
*hop.BlindedNodePub, onionMessagePayload,
)
if err != nil {
return nil, fmt.Errorf("sphinx hop %v: %w", i, err)
}
path[i] = *hop
}
return &path, nil
}
// createSphinxHop encodes an onion message payload and produces a sphinx
// onion hop for it.
func createSphinxHop(nodeID btcec.PublicKey,
onionMessagePayload *lnwire.OnionMessagePayload) (*sphinx.OnionHop,
error) {
encodeOnionMessagePayload, err := onionMessagePayload.Encode()
if err != nil {
return nil, fmt.Errorf("failed onion message payload encode: "+
"%w", err)
}
hopPayload, err := sphinx.NewTLVHopPayload(encodeOnionMessagePayload)
if err != nil {
return nil, fmt.Errorf("failed creation of tlv hop payload: "+
"%w", err)
}
return &sphinx.OnionHop{
NodePub: nodeID,
HopPayload: hopPayload,
}, nil
}

View file

@ -9447,10 +9447,30 @@ func (r *rpcServer) SubscribeOnionMessages(
"failed type assertion: %T", update)
}
err := server.Send(&lnrpc.OnionMessage{
Peer: oMsg.Peer[:],
PathKey: oMsg.PathKey[:],
Onion: oMsg.OnionBlob,
bp := &lnrpc.BlindedPath{}
//nolint:ll
if oMsg.ReplyPath != nil {
bp.IntroductionNode = oMsg.ReplyPath.IntroductionPoint.SerializeCompressed()
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)
}
}
//nolint:ll
err := server.Send(&lnrpc.OnionMessageUpdate{
Peer: oMsg.Peer[:],
PathKey: oMsg.PathKey[:],
Onion: oMsg.OnionBlob,
ReplyPath: bp,
EncryptedRecipientData: oMsg.EncryptedRecipientData,
CustomRecords: oMsg.CustomRecords,
})
if err != nil {
return err

View file

@ -1450,6 +1450,9 @@
; Set to enable support for RBF based coop close.
; protocol.rbf-coop-close=false
; set to disable onion message support.
; protocol.no-onion-messages=false
; Set to handle messages of a particular type that falls outside of the
; custom message number range (i.e. 513 is onion messages). Note that you can
; set this option as many times as you want to support more than one custom

View file

@ -27,6 +27,7 @@ import (
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btclog/v2"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/actor"
"github.com/lightningnetwork/lnd/aliasmgr"
"github.com/lightningnetwork/lnd/autopilot"
"github.com/lightningnetwork/lnd/brontide"
@ -67,6 +68,7 @@ import (
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/nat"
"github.com/lightningnetwork/lnd/netann"
"github.com/lightningnetwork/lnd/onionmessage"
paymentsdb "github.com/lightningnetwork/lnd/payments/db"
"github.com/lightningnetwork/lnd/peer"
"github.com/lightningnetwork/lnd/peernotifier"
@ -376,7 +378,9 @@ type server struct {
chainArb *contractcourt.ChainArbitrator
sphinx *hop.OnionProcessor
sphinxPayment *hop.OnionProcessor
sphinxOnionMsg *sphinx.Router
towerClientMgr *wtclient.Manager
@ -421,6 +425,15 @@ type server struct {
onionMessageServer *subscribe.Server
// actorSystem is the actor system tasked with handling actors that are
// created for this server.
actorSystem *actor.ActorSystem
// onionActorFactory is a factory function that spawns per-peer onion
// message actors. It captures shared dependencies and is passed to
// each peer connection.
onionActorFactory onionmessage.OnionActorFactory
// txPublisher is a publisher with fee-bumping capability.
txPublisher *sweep.TxPublisher
@ -606,6 +619,12 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr,
)
sphinxRouter := sphinx.NewRouter(nodeKeyECDH, replayLog)
// Initialize the onion message sphinx router. This router doesn't need
// replay protection.
sphinxOnionMsg := sphinx.NewRouter(
nodeKeyECDH, sphinx.NewNoOpReplayLog(),
)
writeBufferPool := pool.NewWriteBuffer(
pool.DefaultWriteBufferGCInterval,
pool.DefaultWriteBufferExpiryInterval,
@ -649,6 +668,7 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr,
NoTaprootChans: !cfg.ProtocolOptions.TaprootChans,
NoTaprootOverlay: !cfg.ProtocolOptions.TaprootOverlayChans,
NoRouteBlinding: cfg.ProtocolOptions.NoRouteBlinding(),
NoOnionMessages: cfg.ProtocolOptions.NoOnionMessages(),
NoExperimentalAccountability: cfg.ProtocolOptions.NoExpAccountability(),
NoQuiescence: cfg.ProtocolOptions.NoQuiescence(),
NoRbfCoopClose: !cfg.ProtocolOptions.RbfCoopClose,
@ -707,7 +727,8 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr,
// TODO(roasbeef): derive proper onion key based on rotation
// schedule
sphinx: hop.NewOnionProcessor(sphinxRouter),
sphinxPayment: hop.NewOnionProcessor(sphinxRouter),
sphinxOnionMsg: sphinxOnionMsg,
torController: torController,
@ -733,6 +754,8 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr,
onionMessageServer: subscribe.NewServer(),
actorSystem: actor.NewActorSystem(),
tlsManager: tlsManager,
featureMgr: featureMgr,
@ -794,7 +817,7 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr,
},
FwdingLog: dbs.ChanStateDB.ForwardingLog(),
SwitchPackager: channeldb.NewSwitchPackager(),
ExtractErrorEncrypter: s.sphinx.ExtractErrorEncrypter,
ExtractErrorEncrypter: s.sphinxPayment.ExtractErrorEncrypter,
FetchLastChannelUpdate: s.fetchLastChanUpdate(),
Notifier: s.cc.ChainNotifier,
HtlcNotifier: s.htlcNotifier,
@ -1347,7 +1370,7 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr,
Registry: s.invoices,
NotifyClosedChannel: s.channelNotifier.NotifyClosedChannelEvent,
NotifyFullyResolvedChannel: s.channelNotifier.NotifyFullyResolvedChannelEvent,
OnionProcessor: s.sphinx,
OnionProcessor: s.sphinxPayment,
PaymentsExpirationGracePeriod: cfg.PaymentsExpirationGracePeriod,
IsForwardedHTLC: s.htlcSwitch.IsForwardedHTLC,
Clock: clock.NewDefaultClock(),
@ -2332,12 +2355,34 @@ func (s *server) Start(ctx context.Context) error {
return
}
cleanup = cleanup.add(s.sphinx.Stop)
if err := s.sphinx.Start(); err != nil {
cleanup = cleanup.add(s.sphinxPayment.Stop)
if err := s.sphinxPayment.Start(); err != nil {
startErr = err
return
}
cleanup = cleanup.add(func() error {
s.sphinxOnionMsg.Stop()
return nil
})
if err := s.sphinxOnionMsg.Start(); err != nil {
startErr = err
return
}
// Create the onion message actor factory that will be used to
// spawn per-peer actors for handling onion messages. Skip if
// onion messaging is disabled via config.
if !s.cfg.ProtocolOptions.NoOnionMessages() {
resolver := onionmessage.NewGraphNodeResolver(
s.graphDB, s.identityECDH.PubKey(),
)
s.onionActorFactory = onionmessage.NewOnionActorFactory(
s.sphinxOnionMsg, resolver, s,
s.onionMessageServer,
)
}
cleanup = cleanup.add(s.chanStatusMgr.Stop)
if err := s.chanStatusMgr.Start(); err != nil {
startErr = err
@ -2605,6 +2650,9 @@ func (s *server) Stop() error {
// Stop dispatching blocks to other systems immediately.
s.blockbeatDispatcher.Stop()
// Shutdown the onion router for onion messaging.
s.sphinxOnionMsg.Stop()
// Shutdown the wallet, funding manager, and the rpc server.
if err := s.chanStatusMgr.Stop(); err != nil {
srvrLog.Warnf("failed to stop chanStatusMgr: %v", err)
@ -2612,7 +2660,7 @@ func (s *server) Stop() error {
if err := s.htlcSwitch.Stop(); err != nil {
srvrLog.Warnf("failed to stop htlcSwitch: %v", err)
}
if err := s.sphinx.Stop(); err != nil {
if err := s.sphinxPayment.Stop(); err != nil {
srvrLog.Warnf("failed to stop sphinx: %v", err)
}
if err := s.invoices.Stop(); err != nil {
@ -4400,14 +4448,15 @@ func (s *server) peerConnected(conn net.Conn, connReq *connmgr.ConnReq,
ChainNotifier: s.cc.ChainNotifier,
BestBlockView: s.cc.BestBlockTracker,
RoutingPolicy: s.cc.RoutingPolicy,
Sphinx: s.sphinx,
SphinxPayment: s.sphinxPayment,
SpawnOnionActor: s.onionActorFactory,
ActorSystem: s.actorSystem,
WitnessBeacon: s.witnessBeacon,
Invoices: s.invoices,
ChannelNotifier: s.channelNotifier,
HtlcNotifier: s.htlcNotifier,
TowerClient: towerClient,
DisconnectPeer: s.DisconnectPeer,
OnionMessageServer: s.onionMessageServer,
GenNodeAnnouncement: func(...netann.NodeAnnModifier) (
lnwire.NodeAnnouncement1, error) {
@ -4664,6 +4713,10 @@ func (s *server) peerTerminationWatcher(p *peer.Brontide, ready chan struct{}) {
if _, ok := s.ignorePeerTermination[p]; ok {
delete(s.ignorePeerTermination, p)
// Ensure the onion peer actor is stopped even if Disconnect
// hasn't been called yet due to async execution.
p.StopOnionActorIfExists()
pubKey := p.PubKey()
pubStr := string(pubKey[:])
@ -5345,6 +5398,20 @@ func (s *server) SendOnionMessage(ctx context.Context, peerPub [33]byte,
return peer.SendMessageLazy(true, msg)
}
// SendToPeer sends an onion message to the peer identified by the given
// compressed public key. This implements the onionmessage.PeerMessageSender
// interface and is used by the onion peer actor when forwarding messages.
func (s *server) SendToPeer(pubKey [33]byte,
msg *lnwire.OnionMessage) error {
peer, err := s.FindPeerByPubStr(string(pubKey[:]))
if err != nil {
return err
}
return peer.SendMessageLazy(true, msg)
}
// newSweepPkScriptGen creates closure that generates a new public key script
// which should be used to sweep any funds into the on-chain wallet.
// Specifically, the script generated is a version 0, pay-to-witness-pubkey-hash