multi: use feature bits to pick which taproot nonce field to use
What changed, and why it matters
This commit changes how Lightning taproot channels send cryptographic nonces during channel recovery and revocation. Previously, both an old single-nonce field and a new map-of-nonces field were always sent. Now, only one is sent depending on whether the channel is a 'staging' or 'final' taproot channel type. This is a protocol-correctness and compatibility change, not a clear security bug fix, though it reduces ambiguity in peer communication.
Review whether any peer still expects both fields or misinterprets the absence of the legacy field as missing data. Ensure customSigningRand cannot be set in production builds. Consider adding fuzz/negative tests for LocalVerNonce when neither field is populated or the map lacks the funding txid.
Security signals we found
MuSig2 nonce handling changed from dual-field to single-field based on feature bit
New deterministic nonce randomness option added with explicit private-key-extraction warning
Protocol field selection now depends on IsTaprootFinal channel type
Helper abstracts nonce extraction with map-first, legacy-second fallback
Evidence from the diff
The patch modifies ChanSyncMsg, generateRevocation, and channel_ready resend logic to select between the legacy LocalNonce and the new LocalNonces map based on ChanType.IsTaprootFinal(). It adds a LocalVerNonce helper on ChannelReestablish that prefers the map and falls back to the legacy field. Tests are updated to assert that staging taproot channels populate only LocalNonce, while final taproot channels populate only LocalNonces. A new customSigningRand option is added to channelOpts with a warning that it is for test-vector generation only because deterministic nonces can leak private keys.
Changed components
channeldb/channel.go ChanSyncMsghtlcswitch/link.go syncChanStates channel_ready resendlnwallet/channel.go generateRevocation and channelOptslnwire/channel_reestablish.go LocalVerNonce helperlnwallet/channel_revoke_nonces_test.golnwallet/channel_test.goInspect captured patch +151 / −95
diff --git a/channeldb/channel.go b/channeldb/channel.go
index 502d755..947226c 100644
--- a/channeldb/channel.go
+++ b/channeldb/channel.go
@@ -1914,6 +1914,7 @@ func NewMusigVerificationNonce(pubKey *btcec.PublicKey, targetHeight uint64,
// modify our typical chan sync message to ensure they force close even if
// we're on the very first state.
func (c *OpenChannel) ChanSyncMsg() (*lnwire.ChannelReestablish, error) {
+
c.Lock()
defer c.Unlock()
@@ -1993,18 +1994,21 @@ func (c *OpenChannel) ChanSyncMsg() (*lnwire.ChannelReestablish, error) {
"nonce: %w", err)
}
- // Populate the legacy LocalNonce field for backwards
- // compatibility.
- nextTaprootNonce = lnwire.SomeMusig2Nonce(nextNonce.PubNonce)
-
- // Also populate the new LocalNonces field. For channel
- // re-establishment, we'll key our nonce by the funding txid.
fundingTxid := c.FundingOutpoint.Hash
- noncesMap := make(map[chainhash.Hash]lnwire.Musig2Nonce)
- noncesMap[fundingTxid] = nextNonce.PubNonce
- nextLocalNonces = lnwire.SomeLocalNonces(
- lnwire.LocalNoncesData{NoncesMap: noncesMap},
- )
+ nonce := nextNonce.PubNonce
+
+ // Final taproot channels use the map-based LocalNonces
+ // field keyed by funding TXID. Staging channels use the
+ // legacy single LocalNonce field.
+ if c.ChanType.IsTaprootFinal() {
+ noncesMap := make(map[chainhash.Hash]lnwire.Musig2Nonce)
+ noncesMap[fundingTxid] = nonce
+ nextLocalNonces = lnwire.SomeLocalNonces(
+ lnwire.LocalNoncesData{NoncesMap: noncesMap},
+ )
+ } else {
+ nextTaprootNonce = lnwire.SomeMusig2Nonce(nonce)
+ }
}
return &lnwire.ChannelReestablish{
diff --git a/htlcswitch/link.go b/htlcswitch/link.go
index 641f046..dd0f5d3 100644
--- a/htlcswitch/link.go
+++ b/htlcswitch/link.go
@@ -918,7 +918,8 @@ func (l *channelLink) syncChanStates(ctx context.Context) error {
// First, we'll generate our ChanSync message to send to the other
// side. Based on this message, the remote party will decide if they
- // need to retransmit any data or not.
+ // need to retransmit any data or not. The nonce format is derived from
+ // the channel type internally.
localChanSyncMsg, err := chanState.ChanSyncMsg()
if err != nil {
return fmt.Errorf("unable to generate chan sync message for "+
@@ -968,10 +969,21 @@ func (l *channelLink) syncChanStates(ctx context.Context) error {
// If this is a taproot channel, then we'll send the
// very same nonce that we sent above, as they should
- // take the latest verification nonce we send.
+ // take the latest verification nonce we send. We use
+ // LocalVerNonce to extract from the correct field
+ // (map-based for final, legacy for staging).
if chanState.ChanType.IsTaproot() {
- //nolint:ll
- channelReadyMsg.NextLocalNonce = localChanSyncMsg.LocalNonce
+ nonce, err := localChanSyncMsg.LocalVerNonce(
+ chanState.FundingOutpoint.Hash,
+ )
+ if err != nil {
+ return fmt.Errorf("unable to "+
+ "extract nonce for "+
+ "channel_ready resend: %w",
+ err)
+ }
+
+ channelReadyMsg.NextLocalNonce = lnwire.SomeMusig2Nonce(nonce) //nolint:ll
}
// For channels that negotiated the option-scid-alias
diff --git a/lnwallet/channel.go b/lnwallet/channel.go
index eef75be..5312ae9 100644
--- a/lnwallet/channel.go
+++ b/lnwallet/channel.go
@@ -7,6 +7,7 @@ import (
"crypto/sha256"
"errors"
"fmt"
+ "io"
"slices"
"sync"
@@ -861,6 +862,14 @@ type channelOpts struct {
auxHtlcValidator fn.Option[AuxHtlcValidator]
skipNonceInit bool
+
+ // customSigningRand is an optional custom random source for generating
+ // deterministic JIT signing nonces in MuSig2 sessions.
+ //
+ // WARNING: This MUST only be used for test vector generation. Setting
+ // this in production will produce deterministic nonces, enabling
+ // private key extraction via nonce reuse.
+ customSigningRand fn.Option[io.Reader]
}
// WithLocalMusigNonces is used to bind an existing verification/local nonce to
@@ -913,11 +922,10 @@ func WithAuxResolver(resolver AuxContractResolver) ChannelOpt {
}
// WithAuxHtlcValidator is used to specify a custom HTLC validator for the
-// channel. This validator will be called during HTLC addition to perform
-// final validation checks against the most up-to-date channel state.
+// channel.
func WithAuxHtlcValidator(validator AuxHtlcValidator) ChannelOpt {
return func(o *channelOpts) {
- o.auxHtlcValidator = fn.Some(validator)
+ o.auxHtlcValidator = fn.Some[AuxHtlcValidator](validator)
}
}
@@ -9535,20 +9543,24 @@ func (lc *LightningChannel) generateRevocation(height uint64) (*lnwire.RevokeAnd
return nil, err
}
- // Populate the legacy LocalNonce field for backwards
- // compatibility.
- revocationMsg.LocalNonce = lnwire.SomeMusig2Nonce(
- nextVerificationNonce.PubNonce,
- )
-
- // Also populate the new LocalNonces field. For revoke and ack,
- // we'll key our nonce by the funding txid.
fundingTxid := lc.channelState.FundingOutpoint.Hash
- noncesMap := make(map[chainhash.Hash]lnwire.Musig2Nonce)
- noncesMap[fundingTxid] = nextVerificationNonce.PubNonce
- revocationMsg.LocalNonces = lnwire.SomeLocalNonces(
- lnwire.LocalNoncesData{NoncesMap: noncesMap},
- )
+ nonce := nextVerificationNonce.PubNonce
+
+ // Set the appropriate nonce field based on the channel type.
+ // Final taproot channels use the map-based LocalNonces field,
+ // while staging taproot channels use the legacy single
+ // LocalNonce field.
+ if lc.channelState.ChanType.IsTaprootFinal() {
+ noncesMap := make(map[chainhash.Hash]lnwire.Musig2Nonce)
+ noncesMap[fundingTxid] = nonce
+ revocationMsg.LocalNonces = lnwire.SomeLocalNonces(
+ lnwire.LocalNoncesData{
+ NoncesMap: noncesMap,
+ },
+ )
+ } else {
+ revocationMsg.LocalNonce = lnwire.SomeMusig2Nonce(nonce)
+ }
}
return revocationMsg, nil
diff --git a/lnwallet/channel_revoke_nonces_test.go b/lnwallet/channel_revoke_nonces_test.go
index a203b4d..81848d4 100644
--- a/lnwallet/channel_revoke_nonces_test.go
+++ b/lnwallet/channel_revoke_nonces_test.go
@@ -67,70 +67,46 @@ func generateAndProcessRevocation(t *testing.T, chanType channeldb.ChannelType,
}
// TestRevokeAndAckTaprootLocalNonces tests that the RevokeAndAck message
-// properly populates and parses both the legacy LocalNonce field and the new
-// LocalNonces field for taproot channels. This ensures backwards compatibility
-// while supporting future splice operations that may require multiple nonces.
+// properly populates the nonce fields based on the channel type.
+// Staging taproot channels populate only LocalNonce (legacy behavior).
+// Final taproot channels populate only LocalNonces (map-based).
+// This ensures backwards compatibility while supporting production peers.
func TestRevokeAndAckTaprootLocalNonces(t *testing.T) {
t.Parallel()
chanType := channeldb.SimpleTaprootFeatureBit
- t.Run("both fields populated", func(t *testing.T) {
+ t.Run("legacy nonce type only populates LocalNonce", func(t *testing.T) {
t.Parallel()
+ // Staging taproot channels populate only the
+ // LocalNonce field (legacy behavior).
revMsg, _, _, err := generateAndProcessRevocation(
t, chanType, nil,
)
require.NoError(t, err)
- // Verify both fields are populated.
+ // Verify only LocalNonce is populated (legacy behavior).
require.True(
t, revMsg.LocalNonce.IsSome(),
- "LocalNonce should be populated",
+ "LocalNonce should be populated for legacy nonce type",
)
require.True(
- t, revMsg.LocalNonces.IsSome(),
- "LocalNonces should be populated",
+ t, revMsg.LocalNonces.IsNone(),
+ "LocalNonces should NOT be populated for legacy nonce type",
)
})
- t.Run("nonces match between fields", func(t *testing.T) {
+ t.Run("extracted nonce from legacy field", func(t *testing.T) {
t.Parallel()
- revMsg, _, bobChannel, err := generateAndProcessRevocation(
+ revMsg, _, _, err := generateAndProcessRevocation(
t, chanType, nil,
)
require.NoError(t, err)
- // Verify that the noncee map field is populated and is keyed
- // properly.
- noncesData := revMsg.LocalNonces.UnwrapOrFail(t)
- require.Len(
- t, noncesData.NoncesMap, 1,
- "LocalNonces map should contain exactly one entry",
- )
- var mapNonce lnwire.Musig2Nonce
- for txid, nonce := range noncesData.NoncesMap {
- mapNonce = nonce
-
- // Verify it's keyed by funding txid.
- //
- //nolint:ll
- fundingTxid := bobChannel.channelState.FundingOutpoint.Hash
- require.Equal(
- t, fundingTxid, txid,
- "Nonce should be keyed by funding txid",
- )
- break
- }
-
+ // Verify we can extract the nonce from the legacy field.
legacyNonce := revMsg.LocalNonce.UnwrapOrFailV(t)
-
- // Both nonces should match.
- require.Equal(
- t, legacyNonce, mapNonce,
- "Nonces in LocalNonce and LocalNonces should match",
- )
extractedNonce := extractRevocationNonce(t, revMsg)
require.Equal(
t, legacyNonce, extractedNonce,
diff --git a/lnwallet/channel_test.go b/lnwallet/channel_test.go
index 2b8fdb0..3c94c3c 100644
--- a/lnwallet/channel_test.go
+++ b/lnwallet/channel_test.go
@@ -3587,7 +3587,9 @@ func testChanSyncOweCommitment(t *testing.T,
}
// TestChanSyncTaprootLocalNonces tests the nonce synchronization behavior for
-// taproot channels using both LocalNonce and LocalNonces fields.
+// taproot channels. The nonce field populated depends on the channel type:
+// - Staging taproot (SimpleTaprootFeatureBit): only LocalNonce is populated.
+// - Final taproot (TaprootFinalBit): only LocalNonces is populated.
func TestChanSyncTaprootLocalNonces(t *testing.T) {
t.Parallel()
@@ -3595,57 +3597,69 @@ func TestChanSyncTaprootLocalNonces(t *testing.T) {
aliceChannel, bobChannel, err := CreateTestChannels(t, chanType)
require.NoError(t, err)
+ // Also create a pair of final taproot channels. Final taproot channels
+ // populate the map-based LocalNonces field instead of the legacy
+ // LocalNonce field.
+ finalChanType := channeldb.SimpleTaprootFeatureBit |
+ channeldb.TaprootFinalBit
+ aliceFinalChan, bobFinalChan, err := CreateTestChannels(
+ t, finalChanType,
+ )
+ require.NoError(t, err)
+
fundingTxid := aliceChannel.channelState.FundingOutpoint.Hash
+ finalFundingTxid := aliceFinalChan.channelState.FundingOutpoint.Hash
- t.Run("both fields populated", func(t *testing.T) {
+ t.Run("staging taproot only populates LocalNonce", func(t *testing.T) {
assertNoChanSyncNeeded(t, aliceChannel, bobChannel)
+ // Staging taproot channels populate only the legacy
+ // LocalNonce field.
aliceChanSyncMsg, err := aliceChannel.channelState.ChanSyncMsg()
require.NoError(t, err)
bobChanSyncMsg, err := bobChannel.channelState.ChanSyncMsg()
require.NoError(t, err)
+ // Only LocalNonce should be populated.
require.True(t, aliceChanSyncMsg.LocalNonce.IsSome())
- require.True(t, aliceChanSyncMsg.LocalNonces.IsSome())
+ require.True(t, aliceChanSyncMsg.LocalNonces.IsNone())
require.True(t, bobChanSyncMsg.LocalNonce.IsSome())
- require.True(t, bobChanSyncMsg.LocalNonces.IsSome())
+ require.True(t, bobChanSyncMsg.LocalNonces.IsNone())
})
- t.Run("nonces match between fields", func(t *testing.T) {
- aliceChanSyncMsg, err := aliceChannel.channelState.ChanSyncMsg()
+ t.Run("final taproot only populates LocalNonces", func(t *testing.T) {
+ // Final taproot channels populate only the map-based
+ // LocalNonces field.
+ aliceChanSyncMsg, err := aliceFinalChan.channelState.ChanSyncMsg()
require.NoError(t, err)
- aliceLegacyNonce := aliceChanSyncMsg.LocalNonce.UnwrapOrFailV(t)
- aliceNoncesData := aliceChanSyncMsg.LocalNonces.UnwrapOrFail(t)
- require.Len(t, aliceNoncesData.NoncesMap, 1)
-
- aliceMapNonce, ok := aliceNoncesData.NoncesMap[fundingTxid]
- require.True(t, ok)
- require.Equal(t, aliceLegacyNonce, aliceMapNonce)
+ // Only LocalNonces should be populated.
+ require.True(t, aliceChanSyncMsg.LocalNonce.IsNone())
+ require.True(t, aliceChanSyncMsg.LocalNonces.IsSome())
- extractedNonce := extractCommitmentNonce(
- t, aliceChanSyncMsg, fundingTxid,
- )
- require.Equal(t, aliceLegacyNonce, extractedNonce)
+ noncesData := aliceChanSyncMsg.LocalNonces.UnwrapOrFail(t)
+ require.Len(t, noncesData.NoncesMap, 1)
})
t.Run("sync with only LocalNonces field", func(t *testing.T) {
- aliceChanSyncMsg, err := aliceChannel.channelState.ChanSyncMsg()
+ // Final taproot channels send messages with only the
+ // LocalNonces field populated. Verify that the receiving side
+ // can process such a message.
+ aliceChanSyncMsg, err := aliceFinalChan.channelState.ChanSyncMsg()
require.NoError(t, err)
- bobChanSyncMsg, err := bobChannel.channelState.ChanSyncMsg()
+ bobChanSyncMsg, err := bobFinalChan.channelState.ChanSyncMsg()
require.NoError(t, err)
- aliceModifiedMsg := *aliceChanSyncMsg
- aliceModifiedMsg.LocalNonce = lnwire.OptMusig2NonceTLV{}
-
- bobChannel.pendingVerificationNonce = &musig2.Nonces{
+ bobFinalChan.pendingVerificationNonce = &musig2.Nonces{
PubNonce: extractCommitmentNonce(
- t, bobChanSyncMsg, fundingTxid,
+ t, bobChanSyncMsg, finalFundingTxid,
),
}
- bobMsgsToSend, _, _, err := bobChannel.ProcessChanSyncMsg(
- ctxb, &aliceModifiedMsg,
+ // Bob should be able to process Alice's message which has
+ // only the LocalNonces field populated.
+ bobMsgsToSend, _, _, err := bobFinalChan.ProcessChanSyncMsg(
+ ctxb, aliceChanSyncMsg,
)
require.NoError(t, err)
require.Empty(t, bobMsgsToSend)
diff --git a/lnwire/channel_reestablish.go b/lnwire/channel_reestablish.go
index b7246ab..d6f4cb5 100644
--- a/lnwire/channel_reestablish.go
+++ b/lnwire/channel_reestablish.go
@@ -2,9 +2,11 @@ package lnwire
import (
"bytes"
+ "fmt"
"io"
"github.com/btcsuite/btcd/btcec/v2"
+ "github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/tlv"
)
@@ -102,6 +104,42 @@ type ChannelReestablish struct {
ExtraData ExtraOpaqueData
}
+// LocalVerNonce extracts the local verification nonce from the message,
+// checking the map-based LocalNonces field first (keyed by fundingTxid), then
+// falling back to the legacy single LocalNonce field. This abstracts over the
+// two nonce formats used by staging vs final taproot channels.
+func (a *ChannelReestablish) LocalVerNonce(
+ fundingTxid chainhash.Hash) (Musig2Nonce, error) {
+
+ // Prefer the map-based field (final taproot channels).
+ if a.LocalNonces.IsSome() {
+ noncesData, err := a.LocalNonces.UnwrapOrErr(
+ fmt.Errorf("local nonces not present"),
+ )
+ if err != nil {
+ return Musig2Nonce{}, err
+ }
+
+ nonce, ok := noncesData.NoncesMap[fundingTxid]
+ if !ok {
+ return Musig2Nonce{}, fmt.Errorf("missing nonce "+
+ "for funding txid %v", fundingTxid)
+ }
+
+ return nonce, nil
+ }
+
+ // Fall back to legacy single nonce field (staging taproot channels).
+ nonce, err := a.LocalNonce.UnwrapOrErrV(
+ fmt.Errorf("remote verification nonce not sent"),
+ )
+ if err != nil {
+ return Musig2Nonce{}, err
+ }
+
+ return nonce, nil
+}
+
// A compile time check to ensure ChannelReestablish implements the
// lnwire.Message interface.
var _ Message = (*ChannelReestablish)(nil)
Why this scored 35/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.