From bb05986c0a8e2814554156b96f49358cbb2628fa Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Mon, 11 May 2026 19:04:49 -0700 Subject: [PATCH 1/2] musig: Include pubnonce in session id Multiple signing sessions over the same message are allowed. Including the pubnonce in the session id allows distinguishing the signing sessions. This should be safe as a new secret nonce is used for each signing session, and after the nonce is used, it is still deleted from memory in order to avoid reuse. --- src/musig.cpp | 4 ++-- src/musig.h | 6 +++++- src/script/sign.cpp | 6 ++++-- src/wallet/scriptpubkeyman.h | 2 +- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/musig.cpp b/src/musig.cpp index 706874be2cf..4031b87d06d 100644 --- a/src/musig.cpp +++ b/src/musig.cpp @@ -119,10 +119,10 @@ bool MuSig2SecNonce::IsValid() return m_impl->IsValid(); } -uint256 MuSig2SessionID(const CPubKey& script_pubkey, const CPubKey& part_pubkey, const uint256& sighash) +uint256 MuSig2SessionID(const CPubKey& script_pubkey, const CPubKey& part_pubkey, const uint256& sighash, const std::vector& pubnonce) { HashWriter hasher; - hasher << script_pubkey << part_pubkey << sighash; + hasher << script_pubkey << part_pubkey << sighash << pubnonce; return hasher.GetSHA256(); } diff --git a/src/musig.h b/src/musig.h index f518ae81ba2..1b86d116701 100644 --- a/src/musig.h +++ b/src/musig.h @@ -56,7 +56,11 @@ public: bool IsValid(); }; -uint256 MuSig2SessionID(const CPubKey& script_pubkey, const CPubKey& part_pubkey, const uint256& sighash); +/** + * Computes an arbitrary unique session ID to identify ongoing signing sessions. + * It is the SHA256 of the aggregate xonly key, the participant pubkey, the sighash, and the pubnonce + */ +uint256 MuSig2SessionID(const CPubKey& script_pubkey, const CPubKey& part_pubkey, const uint256& sighash, const std::vector& pubnonce); std::optional> CreateMuSig2AggregateSig(const std::vector& participants, const CPubKey& aggregate_pubkey, const std::vector>& tweaks, const uint256& sighash, const std::map>& pubnonces, const std::map& partial_sigs); diff --git a/src/script/sign.cpp b/src/script/sign.cpp index 6cbcf07e5e2..372ed03d8d9 100644 --- a/src/script/sign.cpp +++ b/src/script/sign.cpp @@ -123,7 +123,7 @@ std::vector MutableTransactionSignatureCreator::CreateMuSig2Nonce(const if (out.empty()) return {}; // Store the secnonce in the SigningProvider - provider.SetMuSig2SecNonce(MuSig2SessionID(script_pubkey, part_pubkey, *sighash), std::move(secnonce)); + provider.SetMuSig2SecNonce(MuSig2SessionID(script_pubkey, part_pubkey, *sighash, out), std::move(secnonce)); return out; } @@ -156,7 +156,9 @@ bool MutableTransactionSignatureCreator::CreateMuSig2PartialSig(const SigningPro if (!sighash.has_value()) return false; // Retrieve the secnonce - uint256 session_id = MuSig2SessionID(script_pubkey, part_pubkey, *sighash); + auto part_pubnonce_it = pubnonces.find(part_pubkey); + if (part_pubnonce_it == pubnonces.end()) return false; + uint256 session_id = MuSig2SessionID(script_pubkey, part_pubkey, *sighash, part_pubnonce_it->second); std::optional> secnonce = provider.GetMuSig2SecNonce(session_id); if (!secnonce || !secnonce->get().IsValid()) return false; diff --git a/src/wallet/scriptpubkeyman.h b/src/wallet/scriptpubkeyman.h index 1bc5249ca81..06ee020a79c 100644 --- a/src/wallet/scriptpubkeyman.h +++ b/src/wallet/scriptpubkeyman.h @@ -299,7 +299,7 @@ private: * must be done in order to prevent nonce reuse. * * The session id is an arbitrary value set by the signer in order for the signing logic - * to find ongoing signing sessions. It is the SHA256 of aggregate xonly key, + participant pubkey + sighash. + * to find ongoing signing sessions, see MuSig2SessionID. */ mutable std::map m_musig2_secnonces; From 2ef6679c2ca87bbe305f45ff3df3d19ec3f60595 Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Mon, 11 May 2026 19:07:49 -0700 Subject: [PATCH 2/2] test: Check that MuSig2 signing does not reuse nonces Run each MuSig2 operation twice to check that new nonces are generated and used throughout signing. --- test/functional/wallet_musig.py | 87 ++++++++++++++++++++------------- 1 file changed, 52 insertions(+), 35 deletions(-) diff --git a/test/functional/wallet_musig.py b/test/functional/wallet_musig.py index 0e3e4377c08..c392abaf86a 100755 --- a/test/functional/wallet_musig.py +++ b/test/functional/wallet_musig.py @@ -12,6 +12,7 @@ from test_framework.test_framework import BitcoinTestFramework from test_framework.util import ( assert_equal, assert_greater_than, + assert_not_equal, ) PRIVKEY_RE = re.compile(r"^tr\((.+?)/.+\)#.{8}$") @@ -111,6 +112,31 @@ class WalletMuSigTest(BitcoinTestFramework): return wallets, psbt + def assert_musig_signer_data(self, first, second, different_key): + assert_equal(first["participant_pubkey"], second["participant_pubkey"]) + assert_equal(first["aggregate_pubkey"], second["aggregate_pubkey"]) + if "leaf_hash" in first: + assert_equal(first["leaf_hash"], second["leaf_hash"]) + else: + assert "leaf_hash" not in second + + assert_not_equal(first[different_key], second[different_key]) + + def assert_musig_aggregate_in_script(self, signer_data, pattern, psbtin): + pubkey = signer_data["aggregate_pubkey"][2:] + if "pkh" in pattern or "pk_h" in pattern: + pubkey = hash160(bytes.fromhex(pubkey)).hex() + if pubkey in psbtin["witness_utxo"]["scriptPubKey"]["hex"]: + return + elif "taproot_scripts" in psbtin: + for leaf_scripts in psbtin["taproot_scripts"]: + if pubkey in leaf_scripts["script"]: + break + else: + assert False, "Aggregate pubkey not seen as output key, or in any scripts" + else: + assert False, "Aggregate pubkey not seen as output key or internal key" + def test_failure_case_1(self, comment, pat): self.log.info(f"Testing {comment}") wallets, psbt = self.setup_musig_scenario(pat) @@ -247,66 +273,57 @@ class WalletMuSigTest(BitcoinTestFramework): part_pks.remove(deriv_path["pubkey"]) assert_equal(len(part_pks), 0) + # Run 2 signing sessions simultaneously to verify no nonce reuse # Add pubnonces nonce_psbts = [] + nonce_psbts2 = [] for i, wallet in enumerate(wallets): if nosign_wallets and i in nosign_wallets: continue - proc = wallet.walletprocesspsbt(psbt=psbt, sighashtype=sighash_type) - assert_equal(proc["complete"], False) - nonce_psbts.append(proc["psbt"]) + for psbt_list in [nonce_psbts, nonce_psbts2]: + proc = wallet.walletprocesspsbt(psbt=psbt, sighashtype=sighash_type) + assert_equal(proc["complete"], False) + psbt_list.append(proc["psbt"]) comb_nonce_psbt = self.nodes[0].combinepsbt(nonce_psbts) + comb_nonce_psbt2 = self.nodes[0].combinepsbt(nonce_psbts2) dec_psbt = self.nodes[0].decodepsbt(comb_nonce_psbt) + dec_psbt2 = self.nodes[0].decodepsbt(comb_nonce_psbt2) assert_equal(len(dec_psbt["inputs"][0]["musig2_pubnonces"]), expected_pubnonces) - for pn in dec_psbt["inputs"][0]["musig2_pubnonces"]: - pubkey = pn["aggregate_pubkey"][2:] - if "pkh" in pattern or "pk_h" in pattern: - pubkey = hash160(bytes.fromhex(pubkey)).hex() - if pubkey in dec_psbt["inputs"][0]["witness_utxo"]["scriptPubKey"]["hex"]: - continue - elif "taproot_scripts" in dec_psbt["inputs"][0]: - for leaf_scripts in dec_psbt["inputs"][0]["taproot_scripts"]: - if pubkey in leaf_scripts["script"]: - break - else: - assert False, "Aggregate pubkey for pubnonce not seen as output key, or in any scripts" - else: - assert False, "Aggregate pubkey for pubnonce not seen as output key or internal key" + assert_equal(len(dec_psbt2["inputs"][0]["musig2_pubnonces"]), expected_pubnonces) + for pn, pn2 in zip(dec_psbt["inputs"][0]["musig2_pubnonces"], dec_psbt2["inputs"][0]["musig2_pubnonces"]): + self.assert_musig_signer_data(pn, pn2, "pubnonce") + self.assert_musig_aggregate_in_script(pn, pattern, dec_psbt["inputs"][0]) # Add partial sigs psig_psbts = [] + psig_psbts2 = [] for i, wallet in enumerate(wallets): if nosign_wallets and i in nosign_wallets: continue - proc = wallet.walletprocesspsbt(psbt=comb_nonce_psbt, sighashtype=sighash_type) - assert_equal(proc["complete"], False) - psig_psbts.append(proc["psbt"]) + for psbt, psbt_list in [(comb_nonce_psbt, psig_psbts), (comb_nonce_psbt2, psig_psbts2)]: + proc = wallet.walletprocesspsbt(psbt=psbt, sighashtype=sighash_type) + assert_equal(proc["complete"], False) + psbt_list.append(proc["psbt"]) comb_psig_psbt = self.nodes[0].combinepsbt(psig_psbts) + comb_psig_psbt2 = self.nodes[0].combinepsbt(psig_psbts2) dec_psbt = self.nodes[0].decodepsbt(comb_psig_psbt) + dec_psbt2 = self.nodes[0].decodepsbt(comb_psig_psbt2) assert_equal(len(dec_psbt["inputs"][0]["musig2_partial_sigs"]), expected_partial_sigs) - for ps in dec_psbt["inputs"][0]["musig2_partial_sigs"]: - pubkey = ps["aggregate_pubkey"][2:] - if "pkh" in pattern or "pk_h" in pattern: - pubkey = hash160(bytes.fromhex(pubkey)).hex() - if pubkey in dec_psbt["inputs"][0]["witness_utxo"]["scriptPubKey"]["hex"]: - continue - elif "taproot_scripts" in dec_psbt["inputs"][0]: - for leaf_scripts in dec_psbt["inputs"][0]["taproot_scripts"]: - if pubkey in leaf_scripts["script"]: - break - else: - assert False, "Aggregate pubkey for partial sig not seen as output key or in any scripts" - else: - assert False, "Aggregate pubkey for partial sig not seen as output key" + assert_equal(len(dec_psbt2["inputs"][0]["musig2_partial_sigs"]), expected_partial_sigs) + for ps, ps2 in zip(dec_psbt["inputs"][0]["musig2_partial_sigs"], dec_psbt2["inputs"][0]["musig2_partial_sigs"]): + self.assert_musig_signer_data(ps, ps2, "partial_sig") + self.assert_musig_aggregate_in_script(ps, pattern, dec_psbt["inputs"][0]) # Non-participant aggregates partial sigs and send finalized = self.nodes[0].finalizepsbt(psbt=comb_psig_psbt, extract=False) - assert_equal(finalized["complete"], True) + finalized2 = self.nodes[0].finalizepsbt(psbt=comb_psig_psbt2, extract=False) + assert_equal(finalized["complete"], finalized2["complete"], True) witness = self.nodes[0].decodepsbt(finalized["psbt"])["inputs"][0]["final_scriptwitness"] + assert_not_equal(witness, self.nodes[0].decodepsbt(finalized2["psbt"])["inputs"][0]["final_scriptwitness"]) if scriptpath: assert_greater_than(len(witness), 1) else: