lnd/onionmessage/test_utils.go
Gijs van Dam ba27627a70 multi: actor-based onion message forwarding
Add onion message forwarding capability using the OnionPeerActor for
communication. Messages are routed through a receptionist pattern where
each peer has a dedicated OnionPeerActor for handling message sends.

The OnionEndpoint uses the sphinx router for decoding and decrypting the
onion message packet and the encrypted recipient data in the payload of
the onion messages.
2026-03-02 15:46:21 +01:00

249 lines
6.8 KiB
Go

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
}