Skip to content

Commit db9bd06

Browse files
committed
staticaddr: reject malformed MuSig2 signing data
Server-supplied nonces and partial signatures are consumed by the static address loop-in and withdrawal MuSig2 signing paths. Reject nil signing info, wrong nonce lengths, and wrong partial signature lengths before registering nonces or combining signatures, so malformed responses cannot be silently zero-padded into signing attempts. Add withdrawal coverage for nil and malformed server signing data.
1 parent c789882 commit db9bd06

4 files changed

Lines changed: 205 additions & 6 deletions

File tree

staticaddr/loopin/manager.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -379,12 +379,14 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context,
379379
return err
380380
}
381381

382-
var (
383-
serverNonce [musig2.PubNonceSize]byte
384-
sigHash [32]byte
385-
)
382+
var sigHash [32]byte
383+
384+
serverNonce, err := byteSliceTo66ByteSlice(nonce)
385+
if err != nil {
386+
return fmt.Errorf("invalid server nonce for "+
387+
"deposit %v: %w", depositOutpoint, err)
388+
}
386389

387-
copy(serverNonce[:], nonce)
388390
musig2Session, err := staticutil.CreateMusig2Session(
389391
ctx, m.cfg.Signer, loopIn.AddressParams, loopIn.Address,
390392
)

staticaddr/loopin/manager_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package loopin
22

33
import (
4+
"bytes"
45
"context"
56
"errors"
67
"testing"
78

9+
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
810
"github.com/btcsuite/btcd/btcutil"
11+
"github.com/btcsuite/btcd/btcutil/psbt"
912
"github.com/btcsuite/btcd/chaincfg/chainhash"
1013
"github.com/btcsuite/btcd/wire"
1114
"github.com/lightninglabs/loop"
@@ -14,6 +17,7 @@ import (
1417
"github.com/lightninglabs/loop/staticaddr/deposit"
1518
"github.com/lightninglabs/loop/staticaddr/script"
1619
"github.com/lightninglabs/loop/swap"
20+
"github.com/lightninglabs/loop/swapserverrpc"
1721
"github.com/lightningnetwork/lnd/lntypes"
1822
"github.com/lightningnetwork/lnd/routing/route"
1923
"github.com/lightningnetwork/lnd/zpay32"
@@ -220,6 +224,80 @@ func TestInitiateLoopInAllowsReservedAutoloopLabel(t *testing.T) {
220224
require.Equal(t, selectedDeposit.Value, quoteGetter.amount)
221225
}
222226

227+
// TestHandleLoopInSweepReqRejectsInvalidServerNonce ensures that a malformed
228+
// MuSig2 nonce returned by the server is rejected before it reaches the signer.
229+
func TestHandleLoopInSweepReqRejectsInvalidServerNonce(t *testing.T) {
230+
ctx := t.Context()
231+
232+
changeAddr := &script.Parameters{
233+
PkScript: []byte{0xaa, 0xbb},
234+
}
235+
236+
const confirmationHeight = 0
237+
dep := makeDeposit(7, 0, 10_000, confirmationHeight)
238+
depOutpoint := outpointString(dep)
239+
240+
swapHash := lntypes.Hash{9}
241+
loopIn := &StaticAddressLoopIn{
242+
SwapHash: swapHash,
243+
DepositOutpoints: []string{depOutpoint},
244+
SelectedAmount: dep.Value,
245+
}
246+
loopIn.SetState(Succeeded)
247+
248+
sweepTx := makeSweepTx(
249+
[]wire.OutPoint{dep.OutPoint},
250+
[]*wire.TxOut{{
251+
Value: int64(dep.Value),
252+
PkScript: []byte{0xcc, 0xdd},
253+
}},
254+
)
255+
sweepPacket, err := psbt.NewFromUnsignedTx(sweepTx)
256+
require.NoError(t, err)
257+
258+
var psbtBuf bytes.Buffer
259+
require.NoError(t, sweepPacket.Serialize(&psbtBuf))
260+
261+
mgr := &Manager{
262+
cfg: &Config{
263+
AddressManager: &mockAddressManager{
264+
params: changeAddr,
265+
},
266+
DepositManager: &mockDepositManager{
267+
byOutpoint: map[string]*deposit.Deposit{
268+
depOutpoint: dep,
269+
},
270+
},
271+
Store: &mockStore{
272+
loopIns: map[lntypes.Hash]*StaticAddressLoopIn{
273+
swapHash: loopIn,
274+
},
275+
mapIDs: map[lntypes.Hash][]deposit.ID{
276+
swapHash: {dep.ID},
277+
},
278+
},
279+
},
280+
}
281+
282+
req := &swapserverrpc.ServerStaticLoopInSweepNotification{
283+
SweepTxPsbt: psbtBuf.Bytes(),
284+
SwapHash: swapHash[:],
285+
DepositToNonces: map[string][]byte{
286+
depOutpoint: make([]byte, musig2.PubNonceSize-1),
287+
},
288+
PrevoutInfo: []*swapserverrpc.PrevoutInfo{{
289+
Value: uint64(dep.Value),
290+
PkScript: changeAddr.PkScript,
291+
TxidBytes: dep.Hash[:],
292+
OutputIndex: dep.Index,
293+
}},
294+
}
295+
296+
err = mgr.handleLoopInSweepReq(ctx, req)
297+
require.ErrorContains(t, err, "invalid server nonce")
298+
require.ErrorContains(t, err, depOutpoint)
299+
}
300+
223301
// mockDepositManager implements DepositManager for tests.
224302
type mockDepositManager struct {
225303
// activeDeposits is the set returned by GetActiveDepositsInState.

staticaddr/withdraw/manager.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -793,13 +793,32 @@ func (m *Manager) signMusig2Tx(ctx context.Context,
793793

794794
// We'll now add the nonce to our session and sign the tx.
795795
for deposit, sigAndNonce := range sigInfo {
796+
if sigAndNonce == nil {
797+
return nil, fmt.Errorf("missing signing info for "+
798+
"deposit %v", deposit)
799+
}
800+
796801
session, ok := sessions[deposit]
797802
if !ok {
798803
return nil, errors.New("session not found")
799804
}
800805

801-
nonce := [musig2.PubNonceSize]byte{}
806+
if len(sigAndNonce.Nonce) != musig2.PubNonceSize {
807+
return nil, fmt.Errorf("invalid nonce length for "+
808+
"deposit %v: got %d, want %d", deposit,
809+
len(sigAndNonce.Nonce), musig2.PubNonceSize)
810+
}
811+
812+
if len(sigAndNonce.Sig) != input.MuSig2PartialSigSize {
813+
return nil, fmt.Errorf("invalid partial signature "+
814+
"length for deposit %v: got %d, want %d",
815+
deposit, len(sigAndNonce.Sig),
816+
input.MuSig2PartialSigSize)
817+
}
818+
819+
var nonce [musig2.PubNonceSize]byte
802820
copy(nonce[:], sigAndNonce.Nonce)
821+
803822
haveAllNonces, err := signer.MuSig2RegisterNonces(
804823
ctx, session.SessionID,
805824
[][musig2.PubNonceSize]byte{nonce},

staticaddr/withdraw/manager_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"testing"
66

7+
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
78
"github.com/btcsuite/btcd/btcutil"
89
"github.com/btcsuite/btcd/chaincfg"
910
"github.com/btcsuite/btcd/chaincfg/chainhash"
@@ -377,6 +378,105 @@ func TestSignMusig2Tx_MissingOutpointInDepositMap(t *testing.T) {
377378
require.ErrorContains(t, err, "tx outpoint not in deposit index map")
378379
}
379380

381+
// TestSignMusig2Tx_InvalidServerSigningInfo tests that malformed server
382+
// signing data is rejected before it is passed to the signer.
383+
func TestSignMusig2Tx_InvalidServerSigningInfo(t *testing.T) {
384+
t.Parallel()
385+
386+
tx := wire.NewMsgTx(2)
387+
outpoint := wire.OutPoint{
388+
Hash: [32]byte{1},
389+
Index: 0,
390+
}
391+
tx.AddTxIn(&wire.TxIn{
392+
PreviousOutPoint: outpoint,
393+
})
394+
395+
pkScript := []byte{
396+
0x51, 0x20, // OP_1 OP_PUSHBYTES_32
397+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
398+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
399+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
400+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
401+
}
402+
tx.AddTxOut(&wire.TxOut{
403+
Value: 10000,
404+
PkScript: pkScript,
405+
})
406+
407+
depositKey := outpoint.String()
408+
sessions := map[string]*input.MuSig2SessionInfo{
409+
depositKey: {
410+
SessionID: [32]byte{1},
411+
},
412+
}
413+
depositsToIdx := map[string]int{
414+
depositKey: 0,
415+
}
416+
prevOutFetcher := txscript.NewMultiPrevOutFetcher(
417+
map[wire.OutPoint]*wire.TxOut{
418+
outpoint: {
419+
Value: 5000,
420+
PkScript: pkScript,
421+
},
422+
},
423+
)
424+
425+
validNonce := make([]byte, musig2.PubNonceSize)
426+
validSig := make([]byte, input.MuSig2PartialSigSize)
427+
428+
tests := []struct {
429+
name string
430+
signingInfo *swapserverrpc.ServerPsbtWithdrawSigningInfo
431+
errContains string
432+
}{
433+
{
434+
name: "nil signing info",
435+
signingInfo: nil,
436+
errContains: "missing signing info",
437+
},
438+
{
439+
name: "invalid nonce length",
440+
signingInfo: &swapserverrpc.ServerPsbtWithdrawSigningInfo{
441+
Nonce: validNonce[:musig2.PubNonceSize-1],
442+
Sig: validSig,
443+
},
444+
errContains: "invalid nonce length",
445+
},
446+
{
447+
name: "invalid partial signature length",
448+
signingInfo: &swapserverrpc.ServerPsbtWithdrawSigningInfo{
449+
Nonce: validNonce,
450+
Sig: validSig[:input.MuSig2PartialSigSize-1],
451+
},
452+
errContains: "invalid partial signature length",
453+
},
454+
}
455+
456+
lnd := test.NewMockLnd()
457+
m := &Manager{
458+
cfg: &ManagerConfig{
459+
Signer: lnd.Signer,
460+
},
461+
}
462+
463+
for _, tc := range tests {
464+
t.Run(tc.name, func(t *testing.T) {
465+
t.Parallel()
466+
467+
sigInfo := map[string]*swapserverrpc.ServerPsbtWithdrawSigningInfo{
468+
depositKey: tc.signingInfo,
469+
}
470+
471+
_, err := m.signMusig2Tx(
472+
context.Background(), prevOutFetcher, lnd.Signer,
473+
tx.Copy(), sessions, sigInfo, depositsToIdx,
474+
)
475+
require.ErrorContains(t, err, tc.errContains)
476+
})
477+
}
478+
}
479+
380480
// TestCalculateWithdrawalTxValues tests various edge cases in withdrawal
381481
// transaction value calculations.
382482
func TestCalculateWithdrawalTxValues(t *testing.T) {

0 commit comments

Comments
 (0)