lnwire: validate MuSig2 nonce points on wire decode
What changed, and why it matters
This change tightens input checking for a special type of cryptographic value (a MuSig2 nonce) that peers send to each other in Lightning Network messages. Before, a peer could send bytes that looked like a nonce but were not valid points on the Bitcoin curve; those invalid values would only be caught later, deep inside the signing code. Now they are rejected immediately when the message is first decoded. This is a defensive hardening fix: it makes the protocol more robust against malformed or malicious peer input and prevents potential crashes or unexpected behavior in the signing flow.
Treat as a defensive hardening patch. Review whether any other custom cryptographic TLV records in lnwire lack point-on-curve or scalar-range validation, and consider applying similar decode-time checks. No immediate incident response is indicated by the commit alone, but operators should plan to upgrade to a release containing this fix to reduce exposure to malformed peer input.
Security signals we found
Input validation added at wire decode boundary for cryptographic public nonce points
Previously invalid secp256k1 points accepted in 66-byte MuSig2 nonce field could reach MuSig2 session creation
Malformed peer input now rejected with explicit errors before protocol state machine processing
Multiple P2P message types carrying nonces are hardened by the shared decode path
No CVE, advisory, or vendor security disclosure supplied with the commit
Evidence from the diff
The commit adds point-on-curve validation for 66-byte MuSig2 public nonces at the lnwire TLV decode layer. A new ValidateMusig2Nonce helper parses each 33-byte half as a compressed secp256k1 public key using btcec.ParsePubKey. The nonceTypeDecoder and partialSigWithNonceTypeDecoder now call this helper after reading bytes. Test helpers and test cases are updated to generate valid nonces (two compressed public keys) instead of arbitrary 32/66-byte slices. The change affects all messages carrying MuSig2 nonces, including ClosingComplete, ClosingSig, Shutdown, ChannelReestablish, and CommitSig.
Changed components
lnwire/musig2.golnwire/partial_sig.golnwire/test_utils.golnwire/test_message.golnwire/musig2_test.golnwire/commit_sig_test.goMuSig2 nonce TLV decoderPartialSigWithNonce TLV decoderMessages carrying MuSig2 nonces: ClosingComplete, ClosingSig, Shutdown, ChannelReestablish, CommitSigInspect captured patch +64 / −17
diff --git a/lnwire/commit_sig_test.go b/lnwire/commit_sig_test.go
index 0772a2f..2524727 100644
--- a/lnwire/commit_sig_test.go
+++ b/lnwire/commit_sig_test.go
@@ -6,7 +6,6 @@ import (
"testing"
"github.com/btcsuite/btcd/btcec/v2"
- "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/lightningnetwork/lnd/tlv"
"github.com/stretchr/testify/require"
)
@@ -42,8 +41,13 @@ func generateCommitSigTestCases(t *testing.T) []commitSigTestCase {
sigScalar := new(btcec.ModNScalar)
sigScalar.SetByteSlice(sig.RawBytes())
- var nonce [musig2.PubNonceSize]byte
- copy(nonce[:], commitSigBytes)
+ // Generate a valid MuSig2 nonce (two compressed public keys).
+ _, pub1 := btcec.PrivKeyFromBytes(chanIDBytes)
+ _, pub2 := btcec.PrivKeyFromBytes(commitSigBytes[:32])
+
+ var nonce Musig2Nonce
+ copy(nonce[:33], pub1.SerializeCompressed())
+ copy(nonce[33:], pub2.SerializeCompressed())
sigWithNonce := NewPartialSigWithNonce(nonce, *sigScalar)
partialSig := MaybePartialSigWithNonce(sigWithNonce)
diff --git a/lnwire/musig2.go b/lnwire/musig2.go
index 10dbc27..4b69b70 100644
--- a/lnwire/musig2.go
+++ b/lnwire/musig2.go
@@ -1,8 +1,10 @@
package lnwire
import (
+ "fmt"
"io"
+ "github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/lightningnetwork/lnd/tlv"
)
@@ -47,13 +49,36 @@ func nonceTypeEncoder(w io.Writer, val interface{}, _ *[8]byte) error {
return tlv.NewTypeForEncodingErr(val, "lnwire.Musig2Nonce")
}
+// ValidateMusig2Nonce checks that a 66-byte MuSig2 public nonce contains two
+// valid compressed secp256k1 points.
+func ValidateMusig2Nonce(nonce Musig2Nonce) error {
+ const compressedKeyLen = 33
+
+ // A MuSig2 public nonce is two 33-byte compressed public keys (R1, R2).
+ _, err := btcec.ParsePubKey(nonce[:compressedKeyLen])
+ if err != nil {
+ return fmt.Errorf("invalid first nonce point: %w", err)
+ }
+
+ _, err = btcec.ParsePubKey(nonce[compressedKeyLen:])
+ if err != nil {
+ return fmt.Errorf("invalid second nonce point: %w", err)
+ }
+
+ return nil
+}
+
// nonceTypeDecoder is a custom TLV decoder for the Musig2Nonce record.
func nonceTypeDecoder(r io.Reader, val interface{}, _ *[8]byte,
l uint64) error {
if v, ok := val.(*Musig2Nonce); ok && l == musig2.PubNonceSize {
_, err := io.ReadFull(r, v[:])
- return err
+ if err != nil {
+ return err
+ }
+
+ return ValidateMusig2Nonce(*v)
}
return tlv.NewTypeForDecodingErr(
diff --git a/lnwire/musig2_test.go b/lnwire/musig2_test.go
index eac4a72..8f55b5a 100644
--- a/lnwire/musig2_test.go
+++ b/lnwire/musig2_test.go
@@ -3,17 +3,30 @@ package lnwire
import (
"testing"
+ "github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/stretchr/testify/require"
)
-// makeNonce creates a test Musig2Nonce with sequential byte values for testing
-// TLV encoding/decoding.
+// makeNonce creates a test Musig2Nonce containing two valid compressed public
+// keys for testing TLV encoding/decoding.
func makeNonce() Musig2Nonce {
+ _, pub1 := btcec.PrivKeyFromBytes([]byte{
+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
+ 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
+ 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
+ 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
+ })
+ _, pub2 := btcec.PrivKeyFromBytes([]byte{
+ 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28,
+ 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30,
+ 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
+ 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40,
+ })
+
var n Musig2Nonce
- for i := range n {
- n[i] = byte(i)
- }
+ copy(n[:33], pub1.SerializeCompressed())
+ copy(n[33:], pub2.SerializeCompressed())
return n
}
diff --git a/lnwire/partial_sig.go b/lnwire/partial_sig.go
index 1751ae5..d5af8ec 100644
--- a/lnwire/partial_sig.go
+++ b/lnwire/partial_sig.go
@@ -210,6 +210,10 @@ func partialSigWithNonceTypeDecoder(r io.Reader, val interface{}, buf *[8]byte,
return err
}
+ if err := ValidateMusig2Nonce(nonce); err != nil {
+ return err
+ }
+
*v = PartialSigWithNonce{
PartialSig: NewPartialSig(s),
Nonce: nonce,
diff --git a/lnwire/test_message.go b/lnwire/test_message.go
index 9aae7d2..9af7621 100644
--- a/lnwire/test_message.go
+++ b/lnwire/test_message.go
@@ -1742,11 +1742,7 @@ func (c *RevokeAndAck) RandTestMessage(t *rapid.T) Message {
msg.NextRevocationKey = RandPubKey(t)
if rapid.Bool().Draw(t, "includeLocalNonce") {
- var nonce Musig2Nonce
- nonceBytes := rapid.SliceOfN(rapid.Byte(), 32, 32).Draw(
- t, "nonce",
- )
- copy(nonce[:], nonceBytes)
+ nonce := RandMusig2Nonce(t)
msg.LocalNonce = tlv.SomeRecordT(
tlv.NewRecordT[NonceRecordTypeT, Musig2Nonce](nonce),
diff --git a/lnwire/test_utils.go b/lnwire/test_utils.go
index 227640a..602724a 100644
--- a/lnwire/test_utils.go
+++ b/lnwire/test_utils.go
@@ -299,11 +299,16 @@ func RandTLVRecords(t *rapid.T, ignoreRecords fn.Set[uint64],
return customRecords, ignoreSet
}
-// RandMusig2Nonce generates a random musig2 nonce.
+// RandMusig2Nonce generates a random musig2 nonce containing two valid
+// compressed secp256k1 public keys.
func RandMusig2Nonce(t *rapid.T) Musig2Nonce {
+ // A MuSig2 public nonce is two 33-byte compressed public keys.
+ pub1 := RandPubKey(t)
+ pub2 := RandPubKey(t)
+
var nonce Musig2Nonce
- bytes := rapid.SliceOfN(rapid.Byte(), 32, 32).Draw(t, "nonce")
- copy(nonce[:], bytes)
+ copy(nonce[:33], pub1.SerializeCompressed())
+ copy(nonce[33:], pub2.SerializeCompressed())
return nonce
}
Why this scored 62/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.