multi: OnionPeerActor for per-peer message support
What changed, and why it matters
This commit adds a new subsystem for handling 'onion messages' in the LND Lightning node. Onion messages are a way to send data through the Lightning network without a payment. The change introduces a per-peer actor that decodes, routes, and forwards or delivers these messages. It is a large feature addition with new interfaces, routing logic, and tests. There is no direct evidence in the commit that this fixes a known security bug; it appears to be new functionality.
Treat this as a feature commit rather than a security patch. Reviewers should focus on whether the new onion message path introduces denial-of-service or routing risks, particularly around replay protection, path length limits, error handling, and how the actor system isolates per-peer state. No immediate security patch action is indicated by the commit itself.
Security signals we found
New network-facing message handling path added (onion messages)
Use of actor framework for per-peer concurrency isolation
Blinded route processing and ephemeral key derivation
Replay protection delegated to NoOpReplayLog in tests; production replay log choice not visible in this commit
TODO comment notes magic constant 10 used for incomingCltv replay protection
Error paths tested for invalid onion blobs and dispatcher failures
Evidence from the diff
The commit introduces OnionPeerActor and supporting code for BOLT-compliant onion message processing. It adds feature bits 38/39 for onion message support, an actor-based per-peer message pipeline, interfaces for the onion router, peer sender, and update dispatcher, and helpers for building blinded paths and converting them to sphinx payment paths. The actor decodes incoming onion packets via the sphinx router, decrypts blinded hop data, determines forward vs deliver actions, forwards via PeerMessageSender, and dispatches updates. Tests cover forward by node ID, forward by SCID, delivery, concatenated paths, unknown peers, context cancellation, invalid blobs, and dispatcher errors.
Changed components
onionmessage/actor.goonionmessage/hop.goonionmessage/interfaces.goonionmessage/onion_endpoint.gorouting/route/blindedroute.golnwire/features.gogo.modInspect captured patch +1724 / −0
diff --git a/go.mod b/go.mod
index 797a84f..99bfc62 100644
--- a/go.mod
+++ b/go.mod
@@ -33,6 +33,7 @@ require (
github.com/lightninglabs/neutrino v0.16.1
github.com/lightninglabs/neutrino/cache v1.1.2
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
diff --git a/lnwire/features.go b/lnwire/features.go
index 4e927e1..c30cdbf 100644
--- a/lnwire/features.go
+++ b/lnwire/features.go
@@ -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.
//
diff --git a/onionmessage/actor.go b/onionmessage/actor.go
new file mode 100644
index 0000000..f5d15f1
--- /dev/null
+++ b/onionmessage/actor.go
@@ -0,0 +1,246 @@
+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
+}
+
+// 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(
+ 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),
+ )
+}
diff --git a/onionmessage/actor_test.go b/onionmessage/actor_test.go
new file mode 100644
index 0000000..7f5ea6c
--- /dev/null
+++ b/onionmessage/actor_test.go
@@ -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())
+}
diff --git a/onionmessage/errors.go b/onionmessage/errors.go
new file mode 100644
index 0000000..bccc3f0
--- /dev/null
+++ b/onionmessage/errors.go
@@ -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")
+)
diff --git a/onionmessage/hop.go b/onionmessage/hop.go
new file mode 100644
index 0000000..b522b74
--- /dev/null
+++ b/onionmessage/hop.go
@@ -0,0 +1,191 @@
+package onionmessage
+
+import (
+ "bytes"
+
+ "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(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(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(
+ 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(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(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
+}
diff --git a/onionmessage/hop_test.go b/onionmessage/hop_test.go
new file mode 100644
index 0000000..314629f
--- /dev/null
+++ b/onionmessage/hop_test.go
@@ -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(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.
+}
diff --git a/onionmessage/interfaces.go b/onionmessage/interfaces.go
new file mode 100644
index 0000000..15da704
--- /dev/null
+++ b/onionmessage/interfaces.go
@@ -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
+}
diff --git a/onionmessage/onion_endpoint.go b/onionmessage/onion_endpoint.go
index ef1c5ec..f635aa6 100644
--- a/onionmessage/onion_endpoint.go
+++ b/onionmessage/onion_endpoint.go
@@ -5,10 +5,12 @@ import (
"encoding/hex"
"log/slog"
+ sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/btcsuite/btclog/v2"
"github.com/lightningnetwork/lnd/lnutils"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/msgmux"
+ "github.com/lightningnetwork/lnd/record"
"github.com/lightningnetwork/lnd/subscribe"
)
@@ -27,6 +29,19 @@ type OnionMessageUpdate struct {
// manner as onions used to route HTLCs, with the exception that it uses
// blinded routes by default.
OnionBlob []byte
+
+ // 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
}
// OnionEndpoint handles incoming onion messages.
diff --git a/onionmessage/resolver_test.go b/onionmessage/resolver_test.go
new file mode 100644
index 0000000..d857470
--- /dev/null
+++ b/onionmessage/resolver_test.go
@@ -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(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(scid)
+ require.Error(t, err)
+ require.Nil(t, got)
+ })
+}
diff --git a/onionmessage/test_utils.go b/onionmessage/test_utils.go
new file mode 100644
index 0000000..1ee93d7
--- /dev/null
+++ b/onionmessage/test_utils.go
@@ -0,0 +1,248 @@
+package onionmessage
+
+import (
+ "bytes"
+ "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(
+ 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
+}
diff --git a/routing/route/blindedroute.go b/routing/route/blindedroute.go
new file mode 100644
index 0000000..2b8120a
--- /dev/null
+++ b/routing/route/blindedroute.go
@@ -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
+}
Why this scored 31/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.