mirror of
https://github.com/lightningnetwork/lnd.git
synced 2026-08-13 12:32:48 +02:00
Merge pull request #10089 from gijswijs/onion-messaging-1
Onion message forwarding
This commit is contained in:
commit
392d4c8cb3
40 changed files with 7239 additions and 3798 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -111,4 +111,8 @@ var defaultSetDesc = setDesc{
|
|||
SetInit: {}, // I
|
||||
SetNodeAnn: {}, // N
|
||||
},
|
||||
lnwire.OnionMessagesOptional: {
|
||||
SetInit: {}, // I
|
||||
SetNodeAnn: {}, // N
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
11
go.mod
|
|
@ -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
12
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
343
itest/lnd_onion_message_forward_test.go
Normal file
343
itest/lnd_onion_message_forward_test.go
Normal 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[:]
|
||||
}
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
412
lnwire/onion_msg_payload.go
Normal 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)
|
||||
}
|
||||
492
lnwire/onion_msg_payload_test.go
Normal file
492
lnwire/onion_msg_payload_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
251
onionmessage/actor.go
Normal 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
542
onionmessage/actor_test.go
Normal 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
17
onionmessage/errors.go
Normal 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
196
onionmessage/hop.go
Normal 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
288
onionmessage/hop_test.go
Normal 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.
|
||||
}
|
||||
41
onionmessage/interfaces.go
Normal file
41
onionmessage/interfaces.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
121
onionmessage/resolver.go
Normal 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
|
||||
}
|
||||
40
onionmessage/resolver_test.go
Normal file
40
onionmessage/resolver_test.go
Normal 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
249
onionmessage/test_utils.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
87
routing/route/blindedroute.go
Normal file
87
routing/route/blindedroute.go
Normal 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
|
||||
}
|
||||
28
rpcserver.go
28
rpcserver.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
85
server.go
85
server.go
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue