diff --git a/lib/tree/signing_test.go b/lib/tree/signing_test.go index 80a46ec0e..2a6563abc 100644 --- a/lib/tree/signing_test.go +++ b/lib/tree/signing_test.go @@ -25,9 +25,7 @@ type mockMuSig2Signer struct { type mockSession struct { info *input.MuSig2SessionInfo - nonces *musig2.Nonces musigSession *musig2.Session - otherNonces [][66]byte allNoncesKnown bool combinedNonce *[66]byte } @@ -78,7 +76,12 @@ func (m *mockMuSig2Signer) MuSig2CreateSession(version input.MuSig2Version, return nil, err } - musigSession, err := ctx.NewSession() + // The session must sign with the same nonces we advertise in the + // session info below, so hand them to the session explicitly instead + // of letting NewSession generate a second, unrelated pair. + musigSession, err := ctx.NewSession( + musig2.WithPreGeneratedNonce(nonces), + ) if err != nil { return nil, err } @@ -93,9 +96,7 @@ func (m *mockMuSig2Signer) MuSig2CreateSession(version input.MuSig2Version, SessionID: sessionID, PublicNonce: nonces.PubNonce, }, - nonces: nonces, musigSession: musigSession, - otherNonces: nil, } m.sessions[sessionID] = mockSess @@ -138,13 +139,17 @@ func (m *mockMuSig2Signer) MuSig2RegisterCombinedNonce( // Store the combined nonce. session.combinedNonce = &aggNonce - // Register the aggregated nonce with the musig session. - haveAll, err := session.musigSession.RegisterPubNonce(aggNonce) + // Tree signing distributes only the pre-aggregated nonce, so install + // it directly. Registering it via RegisterPubNonce would treat the + // aggregate as one peer's nonce, double-counting our own share in the + // session's combined nonce and leaving haveAll false for any session + // with more than two signers. + err := session.musigSession.RegisterCombinedNonce(aggNonce) if err != nil { return err } - session.allNoncesKnown = haveAll + session.allNoncesKnown = true return nil } @@ -1070,6 +1075,28 @@ func TestMusig2PubNonce(t *testing.T) { }) } +// combinePartialSig folds one counterparty partial signature into the +// signer's own session for the given transaction and returns the final +// combined signature. Combining triggers btcd's internal verification of the +// aggregate against the tweaked combined key, so this is the step that +// surfaces nonce bookkeeping bugs a lone partial signature cannot reveal. +func combinePartialSig(t *testing.T, signer *mockMuSig2Signer, + txSession *TxSignerSession, + partial *musig2.PartialSignature) *schnorr.Signature { + + t.Helper() + + finalSig, haveAll, err := signer.MuSig2CombineSig( + txSession.signSession.SessionID, + []*musig2.PartialSignature{partial}, + ) + require.NoError(t, err) + require.True(t, haveAll) + require.NotNil(t, finalSig) + + return finalSig +} + // TestFullSigningFlow tests the complete signing workflow. func TestFullSigningFlow(t *testing.T) { t.Run("complete 2-party signing for single transaction", @@ -1135,8 +1162,10 @@ func TestFullSigningFlow(t *testing.T) { err = session2.RegisterAggNonces(aggNonceSet) require.NoError(t, err) - // Phase 3: Generate partial signatures. - partialSigs1, err := session1.Signatures(true) + // Phase 3: Generate partial signatures. Signer 1 + // skips cleanup so its session stays alive to act as + // the coordinator and combine both shares below. + partialSigs1, err := session1.Signatures(false) require.NoError(t, err) require.Len(t, partialSigs1, 1) require.NotNil(t, partialSigs1[leafTXID]) @@ -1146,10 +1175,17 @@ func TestFullSigningFlow(t *testing.T) { require.Len(t, partialSigs2, 1) require.NotNil(t, partialSigs2[leafTXID]) - // Note: In production, a coordinator would combine - // these partial signatures. For this test, we verify - // that the signing workflow completes successfully and - // generates partial signatures from both parties. + // Phase 4: Combine the partial signatures and verify + // the aggregate against the leaf's final taproot key. + txSession := session1.txs[leafTXID] + finalSig := combinePartialSig( + t, signer1, txSession, partialSigs2[leafTXID], + ) + require.True( + t, finalSig.Verify( + txSession.sigHash[:], finalKey, + ), + ) }) t.Run("complete 2-party signing for tree with multiple txs", @@ -1260,8 +1296,10 @@ func TestFullSigningFlow(t *testing.T) { err = session2.RegisterAggNonces(aggNonceSet) require.NoError(t, err) - // Generate partial signatures for all txs. - partialSigs1, err := session1.Signatures(true) + // Generate partial signatures for all txs. Signer 1 + // skips cleanup so its sessions stay alive to combine + // the shares for every transaction below. + partialSigs1, err := session1.Signatures(false) require.NoError(t, err) require.Len(t, partialSigs1, 3) @@ -1269,14 +1307,32 @@ func TestFullSigningFlow(t *testing.T) { require.NoError(t, err) require.Len(t, partialSigs2, 3) - // Verify all partial signatures were generated. - for txid := range session1.txs { + // Index each node's final key by transaction ID so + // every combined signature can be verified against + // the key its transaction actually commits to. + finalKeys := make(map[TxID]*btcec.PublicKey) + for node := range root.NodesIter() { + txid, err := node.TXID() + require.NoError(t, err) + finalKeys[txid] = node.FinalKey + } + + // Combine both shares for every transaction and + // verify each aggregate signature. + for txid, txSession := range session1.txs { require.NotNil(t, partialSigs1[txid]) require.NotNil(t, partialSigs2[txid]) - } - // Note: In production, a coordinator would combine - // these partial signatures to create final - // signatures. + finalSig := combinePartialSig( + t, signer1, txSession, + partialSigs2[txid], + ) + require.True( + t, finalSig.Verify( + txSession.sigHash[:], + finalKeys[txid], + ), + ) + } }) } diff --git a/round/harness_test.go b/round/harness_test.go index a3c5906f2..ec8db8162 100644 --- a/round/harness_test.go +++ b/round/harness_test.go @@ -1612,7 +1612,12 @@ func (r *realMuSig2Signer) MuSig2CreateSession(version input.MuSig2Version, return nil, err } - musigSession, err := ctx.NewSession() + // The session must sign with the same nonces we advertise in the + // session info below, so hand them to the session explicitly instead + // of letting NewSession generate a second, unrelated pair. + musigSession, err := ctx.NewSession( + musig2.WithPreGeneratedNonce(nonces), + ) if err != nil { return nil, err } @@ -1671,13 +1676,17 @@ func (r *realMuSig2Signer) MuSig2RegisterCombinedNonce( return fmt.Errorf("session not found: %v", sessionID) } - // Register the combined nonce as if it's from all other participants. - haveAll, err := session.musigSession.RegisterPubNonce(nonce) + // Tree signing distributes only the pre-aggregated nonce, so install + // it directly. Registering it via RegisterPubNonce would treat the + // aggregate as one peer's nonce, double-counting our own share in the + // session's combined nonce and leaving haveAll false for any session + // with more than two signers. + err := session.musigSession.RegisterCombinedNonce(nonce) if err != nil { return err } - session.allNoncesKnown = haveAll + session.allNoncesKnown = true return nil }