What changed, and why it matters
This commit adds a new public method called RegisterCombinedNonce to the MuSig2 multi-signature code in btcd. It lets a signing session accept an already-aggregated public nonce from an external coordinator, instead of requiring each signer to collect every individual nonce itself. The change is a feature addition with explicit safety checks: it rejects registering a combined nonce twice, rejects mixing combined-nonce and individual-nonce registration on the same session, validates the nonce parses as two valid public keys, and preserves the existing one-sign-per-session nonce-reuse guard. Nothing in the commit message or diff suggests a security bug is being fixed; it appears to be a normal API enhancement for coordinator-based signing workflows.
Treat as a routine feature commit. Reviewers may want to confirm that RegisterCombinedNonce is covered by the same session-lifecycle invariants as RegisterPubNonce, and that callers cannot bypass the numSigners consistency check by supplying a combined nonce. No immediate security action is indicated by the supplied materials.
Security signals we found
New externally supplied cryptographic input path added (combined nonce)
Input is validated by parsing both halves as secp256k1 public keys
State-machine guards prevent double registration and mixed registration modes
Existing nonce-reuse protection (ErrSigningContextReuse) remains in effect
No mention of vulnerability, CVE, bug fix, or security issue in commit metadata
Evidence from the diff
The patch introduces Session.RegisterCombinedNonce in btcec/schnorr/musig2/context.go. It stores an externally aggregated [PubNonceSize]byte into s.combinedNonce after parsing both 33-byte halves as valid secp256k1 public keys. It adds a new sentinel error ErrCombinedNonceAfterPubNonces and updates RegisterPubNonce to return ErrAlredyHaveAllNonces if s.combinedNonce is already set. Tests cover the happy path, double-registration, mixed registration, nonce-reuse prevention, and the fact that an incorrect aggregated nonce yields an invalid final signature. No existing behavior is removed, and the new path still relies on the same Sign/CombineSigs/AggregateKeys machinery.
Changed components
btcec/schnorr/musig2/context.gobtcec/schnorr/musig2/musig2_test.goMuSig2 Session APIInspect captured patch +366 / −1
diff --git a/btcec/schnorr/musig2/context.go b/btcec/schnorr/musig2/context.go
index 8e6b715..54c0a43 100644
--- a/btcec/schnorr/musig2/context.go
+++ b/btcec/schnorr/musig2/context.go
@@ -59,6 +59,11 @@ var (
// ErrNotEnoughSigners is returned if a caller attempts to obtain an
// early nonce when it wasn't specified
ErrNoEarlyNonce = fmt.Errorf("no early nonce available")
+
+ // ErrCombinedNonceAfterPubNonces is returned if RegisterCombinedNonce
+ // is called after public nonces have already been registered.
+ ErrCombinedNonceAfterPubNonces = fmt.Errorf("can't register combined " +
+ "nonce after public nonces")
)
// Context is a managed signing context for musig2. It takes care of things
@@ -525,7 +530,7 @@ func (s *Session) RegisterPubNonce(nonce [PubNonceSize]byte) (bool, error) {
// If we already have all the nonces, then this method was called too
// many times.
haveAllNonces := len(s.pubNonces) == s.ctx.opts.numSigners
- if haveAllNonces {
+ if haveAllNonces || s.combinedNonce != nil {
return false, ErrAlredyHaveAllNonces
}
@@ -548,6 +553,43 @@ func (s *Session) RegisterPubNonce(nonce [PubNonceSize]byte) (bool, error) {
return haveAllNonces, nil
}
+// RegisterCombinedNonce allows a caller to directly register a combined nonce
+// that was generated externally. This is useful in coordinator-based
+// protocols where the coordinator aggregates all nonces and distributes the
+// combined nonce to participants, rather than each participant aggregating
+// nonces themselves.
+func (s *Session) RegisterCombinedNonce(
+ combinedNonce [PubNonceSize]byte) error {
+
+ // If we already have a combined nonce, then this method was called too
+ // many times.
+ if s.combinedNonce != nil {
+ return ErrAlredyHaveAllNonces
+ }
+
+ // We also don't allow this method to be called if we already registered
+ // some public nonces.
+ if len(s.pubNonces) > 1 {
+ return ErrCombinedNonceAfterPubNonces
+ }
+
+ // We'll now try to parse the combined nonce into it's two points to
+ // ensure it's valid.
+ _, err := btcec.ParsePubKey(combinedNonce[:33])
+ if err != nil {
+ return fmt.Errorf("invalid combined nonce: %w", err)
+ }
+ _, err = btcec.ParsePubKey(combinedNonce[33:])
+ if err != nil {
+ return fmt.Errorf("invalid combined nonce: %w", err)
+ }
+
+ // Otherwise, we'll just set the combined nonce directly.
+ s.combinedNonce = &combinedNonce
+
+ return nil
+}
+
// Sign generates a partial signature for the target message, using the target
// context. If this method is called more than once per context, then an error
// is returned, as that means a nonce was re-used.
diff --git a/btcec/schnorr/musig2/musig2_test.go b/btcec/schnorr/musig2/musig2_test.go
index dfd48f3..c2efe4a 100644
--- a/btcec/schnorr/musig2/musig2_test.go
+++ b/btcec/schnorr/musig2/musig2_test.go
@@ -439,3 +439,326 @@ func (mr *memsetRandReader) Read(buf []byte) (n int, err error) {
}
return len(buf), nil
}
+
+// TestSigningWithAggregatedNonce tests the aggregated nonce signing flow where
+// nonces are aggregated externally and provided to participants via
+// RegisterCombinedNonce, rather than each participant aggregating nonces
+// themselves via RegisterPubNonce.
+func TestSigningWithAggregatedNonce(t *testing.T) {
+ t.Run("basic flow", func(t *testing.T) {
+ const numSigners = 5
+
+ // Generate signers.
+ signerKeys := make([]*btcec.PrivateKey, numSigners)
+ signSet := make([]*btcec.PublicKey, numSigners)
+ for i := 0; i < numSigners; i++ {
+ privKey, err := btcec.NewPrivateKey()
+ if err != nil {
+ t.Fatalf("unable to gen priv key: %v", err)
+ }
+ signerKeys[i] = privKey
+ signSet[i] = privKey.PubKey()
+ }
+
+ // Each signer creates a context and session.
+ sessions := make([]*Session, numSigners)
+ for i, signerKey := range signerKeys {
+ signCtx, err := NewContext(
+ signerKey, false, WithKnownSigners(signSet),
+ )
+ if err != nil {
+ t.Fatalf("unable to generate context: %v", err)
+ }
+
+ session, err := signCtx.NewSession()
+ if err != nil {
+ t.Fatalf("unable to generate new session: %v", err)
+ }
+ sessions[i] = session
+ }
+
+ // Phase 1: Collect all public nonces.
+ pubNonces := make([][PubNonceSize]byte, numSigners)
+ for i, session := range sessions {
+ pubNonces[i] = session.PublicNonce()
+ }
+
+ // Phase 2: Aggregate nonces externally.
+ combinedNonce, err := AggregateNonces(pubNonces)
+ if err != nil {
+ t.Fatalf("unable to aggregate nonces: %v", err)
+ }
+
+ // Phase 3: Participants register combined nonce and sign.
+ msg := sha256.Sum256([]byte("aggregated nonce signing"))
+
+ partialSigs := make([]*PartialSignature, numSigners)
+ for i, session := range sessions {
+ err = session.RegisterCombinedNonce(combinedNonce)
+ if err != nil {
+ t.Fatalf("signer %d unable to register combined nonce: %v",
+ i, err)
+ }
+ sig, err := session.Sign(msg)
+ if err != nil {
+ t.Fatalf("signer %d unable to sign: %v", i, err)
+ }
+ partialSigs[i] = sig
+ }
+
+ // Phase 4: Combine all partial signatures.
+ finalSig := CombineSigs(partialSigs[0].R, partialSigs)
+
+ // Verify the final signature.
+ combinedKey, _, _, err := AggregateKeys(signSet, false)
+ if err != nil {
+ t.Fatalf("unable to aggregate keys: %v", err)
+ }
+
+ if !finalSig.Verify(msg[:], combinedKey.FinalKey) {
+ t.Fatalf("final signature is invalid")
+ }
+ })
+
+ t.Run("error: register combined nonce twice", func(t *testing.T) {
+ privKey, _ := btcec.NewPrivateKey()
+ privKey2, _ := btcec.NewPrivateKey()
+ signSet := []*btcec.PublicKey{privKey.PubKey(), privKey2.PubKey()}
+
+ signCtx, _ := NewContext(privKey, false, WithKnownSigners(signSet))
+ session, _ := signCtx.NewSession()
+
+ fakeCombinedNonce := getValidNonce(t)
+
+ // First call should succeed.
+ err := session.RegisterCombinedNonce(fakeCombinedNonce)
+ if err != nil {
+ t.Fatalf("first RegisterCombinedNonce failed: %v", err)
+ }
+
+ // Second call should fail.
+ err = session.RegisterCombinedNonce(fakeCombinedNonce)
+ if err != ErrAlredyHaveAllNonces {
+ t.Fatalf("expected ErrAlredyHaveAllNonces, got: %v", err)
+ }
+ })
+
+ t.Run("error: register combined nonce after register pub nonce",
+ func(t *testing.T) {
+
+ privKey, _ := btcec.NewPrivateKey()
+ privKey2, _ := btcec.NewPrivateKey()
+ privKey3, _ := btcec.NewPrivateKey()
+ signSet := []*btcec.PublicKey{
+ privKey.PubKey(),
+ privKey2.PubKey(),
+ privKey3.PubKey(),
+ }
+
+ signCtx, _ := NewContext(privKey, false, WithKnownSigners(signSet))
+ session, _ := signCtx.NewSession()
+
+ signCtx2, _ := NewContext(privKey2, false, WithKnownSigners(signSet))
+ session2, _ := signCtx2.NewSession()
+
+ // Register one public nonce first.
+ _, err := session.RegisterPubNonce(session2.PublicNonce())
+ if err != nil {
+ t.Fatalf("RegisterPubNonce failed: %v", err)
+ }
+
+ // Now try to register a combined nonce - this should fail.
+ fakeCombinedNonce := [PubNonceSize]byte{}
+ err = session.RegisterCombinedNonce(fakeCombinedNonce)
+ if err == nil {
+ t.Fatalf("expected error when calling RegisterCombinedNonce " +
+ "after RegisterPubNonce")
+ }
+ })
+
+ t.Run("error: register pub nonce after register combined nonce",
+ func(t *testing.T) {
+
+ const numSigners = 3
+
+ signerKeys := make([]*btcec.PrivateKey, numSigners)
+ signSet := make([]*btcec.PublicKey, numSigners)
+ for i := 0; i < numSigners; i++ {
+ privKey, _ := btcec.NewPrivateKey()
+ signerKeys[i] = privKey
+ signSet[i] = privKey.PubKey()
+ }
+
+ sessions := make([]*Session, numSigners)
+ for i, signerKey := range signerKeys {
+ signCtx, _ := NewContext(signerKey, false, WithKnownSigners(signSet))
+ session, _ := signCtx.NewSession()
+ sessions[i] = session
+ }
+
+ pubNonces := make([][PubNonceSize]byte, numSigners)
+ for i, session := range sessions {
+ pubNonces[i] = session.PublicNonce()
+ }
+
+ combinedNonce, _ := AggregateNonces(pubNonces)
+
+ // Register the combined nonce first.
+ err := sessions[0].RegisterCombinedNonce(combinedNonce)
+ if err != nil {
+ t.Fatalf("RegisterCombinedNonce failed: %v", err)
+ }
+
+ // Now try to register individual nonces - this should fail.
+ _, err = sessions[0].RegisterPubNonce(pubNonces[1])
+ if err == nil {
+ t.Fatalf("expected error when calling RegisterPubNonce " +
+ "after RegisterCombinedNonce")
+ }
+ })
+
+ t.Run("nonce reuse prevention", func(t *testing.T) {
+ privKey, _ := btcec.NewPrivateKey()
+ privKey2, _ := btcec.NewPrivateKey()
+ signSet := []*btcec.PublicKey{privKey.PubKey(), privKey2.PubKey()}
+
+ signCtx, _ := NewContext(privKey, false, WithKnownSigners(signSet))
+ session, _ := signCtx.NewSession()
+
+ fakeCombinedNonce := getValidNonce(t)
+ session.RegisterCombinedNonce(fakeCombinedNonce)
+
+ msg := sha256.Sum256([]byte("nonce reuse test"))
+
+ // First sign should succeed.
+ _, err := session.Sign(msg)
+ if err != nil {
+ t.Fatalf("first sign failed: %v", err)
+ }
+
+ // Second sign should fail due to nonce reuse.
+ _, err = session.Sign(msg)
+ if err != ErrSigningContextReuse {
+ t.Fatalf("expected nonce reuse error, got: %v", err)
+ }
+ })
+
+ t.Run("incorrect combined nonce produces invalid sig", func(t *testing.T) {
+ const numSigners = 3
+
+ signerKeys := make([]*btcec.PrivateKey, numSigners)
+ signSet := make([]*btcec.PublicKey, numSigners)
+ for i := 0; i < numSigners; i++ {
+ privKey, _ := btcec.NewPrivateKey()
+ signerKeys[i] = privKey
+ signSet[i] = privKey.PubKey()
+ }
+
+ sessions := make([]*Session, numSigners)
+ for i, signerKey := range signerKeys {
+ signCtx, _ := NewContext(signerKey, false, WithKnownSigners(signSet))
+ session, _ := signCtx.NewSession()
+ sessions[i] = session
+ }
+
+ pubNonces := make([][PubNonceSize]byte, numSigners)
+ for i, session := range sessions {
+ pubNonces[i] = session.PublicNonce()
+ }
+
+ // Create INCORRECT combined nonce using only a subset.
+ wrongNonces := pubNonces[:2]
+ incorrectCombinedNonce, _ := AggregateNonces(wrongNonces)
+
+ msg := sha256.Sum256([]byte("incorrect nonce test"))
+
+ partialSigs := make([]*PartialSignature, numSigners)
+ for i, session := range sessions {
+ session.RegisterCombinedNonce(incorrectCombinedNonce)
+ sig, _ := session.Sign(msg)
+ partialSigs[i] = sig
+ }
+
+ finalSig := CombineSigs(partialSigs[0].R, partialSigs)
+ combinedKey, _, _, _ := AggregateKeys(signSet, false)
+
+ // Final signature should be INVALID.
+ if finalSig.Verify(msg[:], combinedKey.FinalKey) {
+ t.Fatalf("final signature should be invalid with incorrect nonce")
+ }
+ })
+
+ t.Run("mixed registration methods", func(t *testing.T) {
+ const numSigners = 4
+
+ signerKeys := make([]*btcec.PrivateKey, numSigners)
+ signSet := make([]*btcec.PublicKey, numSigners)
+ for i := 0; i < numSigners; i++ {
+ privKey, _ := btcec.NewPrivateKey()
+ signerKeys[i] = privKey
+ signSet[i] = privKey.PubKey()
+ }
+
+ sessions := make([]*Session, numSigners)
+ for i, signerKey := range signerKeys {
+ signCtx, _ := NewContext(signerKey, false, WithKnownSigners(signSet))
+ session, _ := signCtx.NewSession()
+ sessions[i] = session
+ }
+
+ pubNonces := make([][PubNonceSize]byte, numSigners)
+ for i, session := range sessions {
+ pubNonces[i] = session.PublicNonce()
+ }
+
+ combinedNonce, _ := AggregateNonces(pubNonces)
+ msg := sha256.Sum256([]byte("mixed registration test"))
+
+ // Half use RegisterCombinedNonce.
+ for i := 0; i < numSigners/2; i++ {
+ sessions[i].RegisterCombinedNonce(combinedNonce)
+ }
+
+ // Other half use RegisterPubNonce.
+ for i := numSigners / 2; i < numSigners; i++ {
+ for j, nonce := range pubNonces {
+ if i == j {
+ continue
+ }
+ sessions[i].RegisterPubNonce(nonce)
+ }
+ }
+
+ // All should be able to sign.
+ partialSigs := make([]*PartialSignature, numSigners)
+ for i, session := range sessions {
+ sig, err := session.Sign(msg)
+ if err != nil {
+ t.Fatalf("signer %d unable to sign: %v", i, err)
+ }
+ partialSigs[i] = sig
+ }
+
+ finalSig := CombineSigs(partialSigs[0].R, partialSigs)
+ combinedKey, _, _, _ := AggregateKeys(signSet, false)
+
+ if !finalSig.Verify(msg[:], combinedKey.FinalKey) {
+ t.Fatalf("final signature is invalid")
+ }
+ })
+}
+
+func getValidNonce(t *testing.T) [PubNonceSize]byte {
+ t.Helper()
+
+ var nonce [PubNonceSize]byte
+
+ privKey, err := btcec.NewPrivateKey()
+ if err != nil {
+ t.Fatalf("unable to gen priv key: %v", err)
+ }
+ copy(nonce[:33], privKey.PubKey().SerializeCompressed())
+ copy(nonce[33:], privKey.PubKey().SerializeCompressed())
+
+ return nonce
+}
Why this scored 17/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.