Merge pull request #1148 from hieblmi/harden-musig2-handling

multi: validate server-provided signing data and clean up comments
This commit is contained in:
Slyghtning 2026-05-29 12:57:23 +02:00 committed by GitHub
commit e0e1da5551
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 389 additions and 44 deletions

View file

@ -78,7 +78,7 @@ func TestAutoLoopDisabled(t *testing.T) {
c.stop()
}
// TestAutoLoopEnabled tests enabling the liquidity manger's autolooper. To keep
// TestAutoLoopEnabled tests enabling the liquidity manager's autolooper. To keep
// the test simple, we do not update actual lnd channel balances, but rather
// run our mock with two channels that will always require a loop out according
// to our rules. This allows us to test the other restrictions placed on the

View file

@ -340,7 +340,7 @@ func (m *Manager) Run(ctx context.Context) error {
}
}
// Try to automatically dispach an asset auto-loop.
// Try to automatically dispatch an asset auto-loop.
for assetID := range m.params.AssetAutoloopParams {
err = m.easyAssetAutoloop(ctx, assetID)
if err != nil {

View file

@ -39,7 +39,7 @@ type PostgresConfig struct {
RequireSSL bool `long:"requiressl" description:"Whether to require using SSL (mode: require) when connecting to the server."`
}
// DSN returns the dns to connect to the database.
// DSN returns the data source name used to connect to the database.
func (s *PostgresConfig) DSN(hidePassword bool) string {
var sslMode = "disable"
if s.RequireSSL {

View file

@ -24,7 +24,7 @@ const (
ProtocolVersionSegwitLoopIn ProtocolVersion = 2
// ProtocolVersionPreimagePush indicates that the client will push loop
// out preimages to the sever to speed up claim.
// out preimages to the server to speed up claim.
ProtocolVersionPreimagePush ProtocolVersion = 3
// ProtocolVersionUserExpiryLoopOut indicates that the client will

View file

@ -478,7 +478,7 @@ func testCustomSweepConfTarget(t *testing.T) {
require.Equal(t, swap.Preimage, preimage)
}
// Now that we have pushed our preimage to the sever, we send an update
// Now that we have pushed our preimage to the server, we send an update
// indicating that our off chain htlc is settled. We do this so that
// we don't have to keep consuming preimage pushes from our server mock
// for every sweep attempt.
@ -779,22 +779,22 @@ func testPreimagePush(t *testing.T) {
require.NoError(t, <-errChan)
}
// TestFailedOffChainCancelation tests sending of a cancelation message to
// TestFailedOffChainCancellation tests sending of a cancellation message to
// the server when a swap fails due to off-chain routing.
func TestFailedOffChainCancelation(t *testing.T) {
func TestFailedOffChainCancellation(t *testing.T) {
t.Run("stable protocol", func(t *testing.T) {
testFailedOffChainCancelation(t)
testFailedOffChainCancellation(t)
})
t.Run("experimental protocol", func(t *testing.T) {
loopdb.EnableExperimentalProtocol()
defer loopdb.ResetCurrentProtocolVersion()
testFailedOffChainCancelation(t)
testFailedOffChainCancellation(t)
})
}
func testFailedOffChainCancelation(t *testing.T) {
func testFailedOffChainCancellation(t *testing.T) {
defer test.Guard(t)()
lnd := test.NewMockLnd()
@ -873,7 +873,7 @@ func testFailedOffChainCancelation(t *testing.T) {
FailureSourceIndex: 1,
},
},
// Add one htlc that failed in the network at wide.
// Add one htlc that failed in the network at large.
{
Status: lnrpc.HTLCAttempt_FAILED,
Route: &lnrpc.Route{
@ -892,7 +892,7 @@ func testFailedOffChainCancelation(t *testing.T) {
State: lnrpc.Payment_SUCCEEDED,
}
// We want to fail our swap payment and succeed the prepush, so we send
// We want to fail our swap payment and succeed the prepayment, so we send
// a failure update to the payment that has the larger amount.
if pmt1.Amount > pmt2.Amount {
pmt1.TrackPaymentMessage.Updates <- failUpdate
@ -908,7 +908,7 @@ func testFailedOffChainCancelation(t *testing.T) {
require.NoError(t, err)
payAddr := invoice.PaymentAddr.UnwrapOrFail(t)
swapCancelation := &outCancelDetails{
swapCancellation := &outCancelDetails{
hash: swap.hash,
paymentAddr: payAddr,
metadata: routeCancelMetadata{
@ -920,7 +920,7 @@ func testFailedOffChainCancelation(t *testing.T) {
},
},
}
server.assertSwapCanceled(t, swapCancelation)
server.assertSwapCanceled(t, swapCancellation)
// Finally, the swap should be recorded with failed off chain timeout.
cfg.store.(*loopdb.StoreMock).AssertLoopOutState(

View file

@ -6,12 +6,14 @@ import (
"testing"
"time"
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/loopdb"
"github.com/lightninglabs/loop/test"
"github.com/lightningnetwork/lnd/input"
invpkg "github.com/lightningnetwork/lnd/invoices"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwire"
@ -32,6 +34,13 @@ var (
testMaxSwapAmount = btcutil.Amount(1000000)
)
// mockMuSig2SigningData returns size-correct placeholder data. Client tests
// only assert response handling, not MuSig2 cryptographic validity.
func mockMuSig2SigningData() ([]byte, []byte, error) {
return make([]byte, musig2.PubNonceSize),
make([]byte, input.MuSig2PartialSigSize), nil
}
// serverMock is used in client unit tests to simulate swap server behaviour.
type serverMock struct {
expectedSwapAmt btcutil.Amount
@ -276,7 +285,7 @@ func (s *serverMock) MuSig2SignSweep(_ context.Context, _ loopdb.ProtocolVersion
_ lntypes.Hash, _ [32]byte, _ []byte, _ []byte) ([]byte,
[]byte, error) {
return nil, nil, nil
return mockMuSig2SigningData()
}
func (s *serverMock) MultiMuSig2SignSweep(ctx context.Context,
@ -285,7 +294,7 @@ func (s *serverMock) MultiMuSig2SignSweep(ctx context.Context,
prevoutMap map[wire.OutPoint]*wire.TxOut) (
[]byte, []byte, error) {
return nil, nil, nil
return mockMuSig2SigningData()
}
func (s *serverMock) PushKey(_ context.Context, _ loopdb.ProtocolVersion,

View file

@ -379,12 +379,14 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context,
return err
}
var (
serverNonce [musig2.PubNonceSize]byte
sigHash [32]byte
)
var sigHash [32]byte
serverNonce, err := byteSliceTo66ByteSlice(nonce)
if err != nil {
return fmt.Errorf("invalid server nonce for "+
"deposit %v: %w", depositOutpoint, err)
}
copy(serverNonce[:], nonce)
musig2Session, err := staticutil.CreateMusig2Session(
ctx, m.cfg.Signer, loopIn.AddressParams, loopIn.Address,
)

View file

@ -1,11 +1,14 @@
package loopin
import (
"bytes"
"context"
"errors"
"testing"
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/btcutil/psbt"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/loop"
@ -14,6 +17,7 @@ import (
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/staticaddr/script"
"github.com/lightninglabs/loop/swap"
"github.com/lightninglabs/loop/swapserverrpc"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/lightningnetwork/lnd/zpay32"
@ -220,6 +224,80 @@ func TestInitiateLoopInAllowsReservedAutoloopLabel(t *testing.T) {
require.Equal(t, selectedDeposit.Value, quoteGetter.amount)
}
// TestHandleLoopInSweepReqRejectsInvalidServerNonce ensures that a malformed
// MuSig2 nonce returned by the server is rejected before it reaches the signer.
func TestHandleLoopInSweepReqRejectsInvalidServerNonce(t *testing.T) {
ctx := t.Context()
changeAddr := &script.Parameters{
PkScript: []byte{0xaa, 0xbb},
}
const confirmationHeight = 0
dep := makeDeposit(7, 0, 10_000, confirmationHeight)
depOutpoint := outpointString(dep)
swapHash := lntypes.Hash{9}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
DepositOutpoints: []string{depOutpoint},
SelectedAmount: dep.Value,
}
loopIn.SetState(Succeeded)
sweepTx := makeSweepTx(
[]wire.OutPoint{dep.OutPoint},
[]*wire.TxOut{{
Value: int64(dep.Value),
PkScript: []byte{0xcc, 0xdd},
}},
)
sweepPacket, err := psbt.NewFromUnsignedTx(sweepTx)
require.NoError(t, err)
var psbtBuf bytes.Buffer
require.NoError(t, sweepPacket.Serialize(&psbtBuf))
mgr := &Manager{
cfg: &Config{
AddressManager: &mockAddressManager{
params: changeAddr,
},
DepositManager: &mockDepositManager{
byOutpoint: map[string]*deposit.Deposit{
depOutpoint: dep,
},
},
Store: &mockStore{
loopIns: map[lntypes.Hash]*StaticAddressLoopIn{
swapHash: loopIn,
},
mapIDs: map[lntypes.Hash][]deposit.ID{
swapHash: {dep.ID},
},
},
},
}
req := &swapserverrpc.ServerStaticLoopInSweepNotification{
SweepTxPsbt: psbtBuf.Bytes(),
SwapHash: swapHash[:],
DepositToNonces: map[string][]byte{
depOutpoint: make([]byte, musig2.PubNonceSize-1),
},
PrevoutInfo: []*swapserverrpc.PrevoutInfo{{
Value: uint64(dep.Value),
PkScript: changeAddr.PkScript,
TxidBytes: dep.Hash[:],
OutputIndex: dep.Index,
}},
}
err = mgr.handleLoopInSweepReq(ctx, req)
require.ErrorContains(t, err, "invalid server nonce")
require.ErrorContains(t, err, depOutpoint)
}
// mockDepositManager implements DepositManager for tests.
type mockDepositManager struct {
// activeDeposits is the set returned by GetActiveDepositsInState.

View file

@ -18,8 +18,8 @@ type Parameters struct {
// used for the 2-of-2 funding output.
ServerPubkey *btcec.PublicKey
// Expiry is the CSV timout value at which the client can claim the
// static address's timout path.
// Expiry is the CSV timeout value at which the client can claim the
// static address's timeout path.
Expiry uint32
// PkScript is the unique static address's output script.

View file

@ -793,13 +793,32 @@ func (m *Manager) signMusig2Tx(ctx context.Context,
// We'll now add the nonce to our session and sign the tx.
for deposit, sigAndNonce := range sigInfo {
if sigAndNonce == nil {
return nil, fmt.Errorf("missing signing info for "+
"deposit %v", deposit)
}
session, ok := sessions[deposit]
if !ok {
return nil, errors.New("session not found")
}
nonce := [musig2.PubNonceSize]byte{}
if len(sigAndNonce.Nonce) != musig2.PubNonceSize {
return nil, fmt.Errorf("invalid nonce length for "+
"deposit %v: got %d, want %d", deposit,
len(sigAndNonce.Nonce), musig2.PubNonceSize)
}
if len(sigAndNonce.Sig) != input.MuSig2PartialSigSize {
return nil, fmt.Errorf("invalid partial signature "+
"length for deposit %v: got %d, want %d",
deposit, len(sigAndNonce.Sig),
input.MuSig2PartialSigSize)
}
var nonce [musig2.PubNonceSize]byte
copy(nonce[:], sigAndNonce.Nonce)
haveAllNonces, err := signer.MuSig2RegisterNonces(
ctx, session.SessionID,
[][musig2.PubNonceSize]byte{nonce},

View file

@ -4,6 +4,7 @@ import (
"context"
"testing"
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/chaincfg/chainhash"
@ -377,6 +378,105 @@ func TestSignMusig2Tx_MissingOutpointInDepositMap(t *testing.T) {
require.ErrorContains(t, err, "tx outpoint not in deposit index map")
}
// TestSignMusig2Tx_InvalidServerSigningInfo tests that malformed server
// signing data is rejected before it is passed to the signer.
func TestSignMusig2Tx_InvalidServerSigningInfo(t *testing.T) {
t.Parallel()
tx := wire.NewMsgTx(2)
outpoint := wire.OutPoint{
Hash: [32]byte{1},
Index: 0,
}
tx.AddTxIn(&wire.TxIn{
PreviousOutPoint: outpoint,
})
pkScript := []byte{
0x51, 0x20, // OP_1 OP_PUSHBYTES_32
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
}
tx.AddTxOut(&wire.TxOut{
Value: 10000,
PkScript: pkScript,
})
depositKey := outpoint.String()
sessions := map[string]*input.MuSig2SessionInfo{
depositKey: {
SessionID: [32]byte{1},
},
}
depositsToIdx := map[string]int{
depositKey: 0,
}
prevOutFetcher := txscript.NewMultiPrevOutFetcher(
map[wire.OutPoint]*wire.TxOut{
outpoint: {
Value: 5000,
PkScript: pkScript,
},
},
)
validNonce := make([]byte, musig2.PubNonceSize)
validSig := make([]byte, input.MuSig2PartialSigSize)
tests := []struct {
name string
signingInfo *swapserverrpc.ServerPsbtWithdrawSigningInfo
errContains string
}{
{
name: "nil signing info",
signingInfo: nil,
errContains: "missing signing info",
},
{
name: "invalid nonce length",
signingInfo: &swapserverrpc.ServerPsbtWithdrawSigningInfo{
Nonce: validNonce[:musig2.PubNonceSize-1],
Sig: validSig,
},
errContains: "invalid nonce length",
},
{
name: "invalid partial signature length",
signingInfo: &swapserverrpc.ServerPsbtWithdrawSigningInfo{
Nonce: validNonce,
Sig: validSig[:input.MuSig2PartialSigSize-1],
},
errContains: "invalid partial signature length",
},
}
lnd := test.NewMockLnd()
m := &Manager{
cfg: &ManagerConfig{
Signer: lnd.Signer,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
sigInfo := map[string]*swapserverrpc.ServerPsbtWithdrawSigningInfo{
depositKey: tc.signingInfo,
}
_, err := m.signMusig2Tx(
context.Background(), prevOutFetcher, lnd.Signer,
tx.Copy(), sessions, sigInfo, depositsToIdx,
)
require.ErrorContains(t, err, tc.errContains)
})
}
}
// TestCalculateWithdrawalTxValues tests various edge cases in withdrawal
// transaction value calculations.
func TestCalculateWithdrawalTxValues(t *testing.T) {

View file

@ -405,13 +405,9 @@ func (s *grpcSwapServerClient) NewLoopOutSwap(ctx context.Context,
return nil, err
}
var senderKey [33]byte
copy(senderKey[:], swapResp.SenderKey)
// Validate sender key.
_, err = btcec.ParsePubKey(senderKey[:])
senderKey, err := parseServerPubKey("sender key", swapResp.SenderKey)
if err != nil {
return nil, fmt.Errorf("invalid sender key: %v", err)
return nil, err
}
return &newLoopOutResponse{
@ -470,14 +466,22 @@ func (s *grpcSwapServerClient) NewLoopInSwap(ctx context.Context,
return nil, err
}
var receiverKey, receiverInternalKey [33]byte
copy(receiverKey[:], swapResp.ReceiverKey)
copy(receiverInternalKey[:], swapResp.ReceiverInternalPubkey)
// Validate receiver key.
_, err = btcec.ParsePubKey(receiverKey[:])
receiverKey, err := parseServerPubKey(
"receiver key", swapResp.ReceiverKey,
)
if err != nil {
return nil, fmt.Errorf("invalid sender key: %v", err)
return nil, err
}
var receiverInternalKey [btcec.PubKeyBytesLenCompressed]byte
if loopdb.CurrentProtocolVersion() >= loopdb.ProtocolVersionMuSig2 {
receiverInternalKey, err = parseServerPubKey(
"receiver internal key",
swapResp.ReceiverInternalPubkey,
)
if err != nil {
return nil, err
}
}
return &newLoopInResponse{
@ -488,6 +492,29 @@ func (s *grpcSwapServerClient) NewLoopInSwap(ctx context.Context,
}, nil
}
// parseServerPubKey validates that keyBytes is a well-formed compressed public
// key received from the server and returns it as a fixed-size array. The name
// argument is used to produce a descriptive error if validation fails.
func parseServerPubKey(name string,
keyBytes []byte) ([btcec.PubKeyBytesLenCompressed]byte, error) {
var key [btcec.PubKeyBytesLenCompressed]byte
if len(keyBytes) != btcec.PubKeyBytesLenCompressed {
return key, fmt.Errorf("invalid %s length: got %d, want %d",
name, len(keyBytes), btcec.PubKeyBytesLenCompressed)
}
_, err := btcec.ParsePubKey(keyBytes)
if err != nil {
return key, fmt.Errorf("invalid %s: %v", name, err)
}
copy(key[:], keyBytes)
return key, nil
}
// ServerUpdate summarizes an update from the swap server.
type ServerUpdate struct {
// State is the state that the server has sent us.

View file

@ -0,0 +1,28 @@
package loop
import (
"testing"
looptest "github.com/lightninglabs/loop/test"
"github.com/stretchr/testify/require"
)
// TestParseServerPubKey ensures that parseServerPubKey accepts a valid
// compressed public key and rejects keys with an invalid length or contents.
func TestParseServerPubKey(t *testing.T) {
t.Parallel()
_, pubKey := looptest.CreateKey(1)
pubKeyBytes := pubKey.SerializeCompressed()
parsedKey, err := parseServerPubKey("test key", pubKeyBytes)
require.NoError(t, err)
require.Equal(t, pubKeyBytes, parsedKey[:])
_, err = parseServerPubKey("test key", pubKeyBytes[:32])
require.ErrorContains(t, err, "invalid test key length")
invalidKey := make([]byte, 33)
_, err = parseServerPubKey("test key", invalidKey)
require.ErrorContains(t, err, "invalid test key")
}

View file

@ -1882,6 +1882,12 @@ func (b *batch) musig2sign(ctx context.Context, inputIndex int, sweep sweep,
return nil, err
}
if err := validateServerMuSig2SigningData(
serverNonce, serverSig,
); err != nil {
return nil, err
}
var serverPublicNonce [musig2.PubNonceSize]byte
copy(serverPublicNonce[:], serverNonce)
@ -1934,6 +1940,26 @@ func (b *batch) musig2sign(ctx context.Context, inputIndex int, sweep sweep,
return finalSig, nil
}
// validateServerMuSig2SigningData rejects malformed MuSig2 cosigning data
// received from the server by checking that the nonce and partial signature
// have the expected lengths before they are passed to the signer.
func validateServerMuSig2SigningData(serverNonce,
serverSig []byte) error {
if len(serverNonce) != musig2.PubNonceSize {
return fmt.Errorf("invalid server nonce length: got %d, "+
"want %d", len(serverNonce), musig2.PubNonceSize)
}
if len(serverSig) != input.MuSig2PartialSigSize {
return fmt.Errorf("invalid server partial signature "+
"length: got %d, want %d", len(serverSig),
input.MuSig2PartialSigSize)
}
return nil
}
// updateRbfRate updates the fee rate we should use for the new batch
// transaction. This fee rate does not guarantee RBF success, but the continuous
// increase leads to an eventual successful RBF replacement.

View file

@ -15,6 +15,7 @@ import (
"github.com/btcsuite/btcd/blockchain"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/chaincfg/chainhash"
@ -87,7 +88,62 @@ func testMuSig2SignSweep(ctx context.Context,
prevoutMap map[wire.OutPoint]*wire.TxOut) (
[]byte, []byte, error) {
return nil, nil, nil
return testMuSig2SigningData()
}
// testMuSig2SigningData returns size-correct placeholder data. These tests
// only exercise control flow around the signing response, not cryptographic
// validity.
func testMuSig2SigningData() ([]byte, []byte, error) {
return make([]byte, musig2.PubNonceSize),
make([]byte, input.MuSig2PartialSigSize), nil
}
// TestValidateServerMuSig2SigningData ensures that MuSig2 cosigning data from
// the server is accepted when well-formed and rejected when the nonce or
// partial signature has an unexpected length.
func TestValidateServerMuSig2SigningData(t *testing.T) {
validNonce, validSig, err := testMuSig2SigningData()
require.NoError(t, err)
testCases := []struct {
name string
serverNonce []byte
serverSig []byte
errContains string
}{
{
name: "valid signing data",
serverNonce: validNonce,
serverSig: validSig,
},
{
name: "invalid nonce length",
serverNonce: validNonce[:musig2.PubNonceSize-1],
serverSig: validSig,
errContains: "invalid server nonce length",
},
{
name: "invalid partial signature length",
serverNonce: validNonce,
serverSig: validSig[:input.MuSig2PartialSigSize-1],
errContains: "invalid server partial signature length",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := validateServerMuSig2SigningData(
tc.serverNonce, tc.serverSig,
)
if tc.errContains == "" {
require.NoError(t, err)
return
}
require.ErrorContains(t, err, tc.errContains)
})
}
}
var customSignature = func() []byte {
@ -5024,7 +5080,7 @@ func testWithMixedBatch(t *testing.T, store testStore,
[]byte, []byte, error) {
if swapHash == swapHashes[2] {
return nil, nil, nil
return testMuSig2SigningData()
} else {
return nil, nil, fmt.Errorf("test error")
}
@ -5377,14 +5433,14 @@ func testWithMixedBatchLarge(t *testing.T, store testStore,
} else {
swapHash2Used = true
return nil, nil, nil
return testMuSig2SigningData()
}
case swapHash == preimages[5].Hash():
return nil, nil, nil
return testMuSig2SigningData()
case swapHash == preimages[8].Hash():
return nil, nil, nil
return testMuSig2SigningData()
default:
return nil, nil, fmt.Errorf("test error")
@ -5431,7 +5487,7 @@ func testWithMixedBatchCoopOnly(t *testing.T, store testStore,
prevoutMap map[wire.OutPoint]*wire.TxOut) (
[]byte, []byte, error) {
return nil, nil, nil
return testMuSig2SigningData()
}
// All the sweeps are cooperative.

View file

@ -67,7 +67,7 @@ func mockMuSig2SignSweep(ctx context.Context,
prevoutMap map[wire.OutPoint]*wire.TxOut) (
[]byte, []byte, error) {
return nil, nil, nil
return mockMuSig2SigningData()
}
func newSwapClient(t *testing.T, config *clientConfig) *Client {