test: Check that MuSig2 signing does not reuse nonces
What changed, and why it matters
This commit only adds a new test to Bitcoin Core's functional test suite. It runs each MuSig2 signing operation twice and checks that different nonces and partial signatures are produced each time. There is no change to wallet or consensus code, so it does not fix or introduce a vulnerability by itself. It is a defensive regression test aimed at catching nonce-reuse bugs in the future.
No immediate action required. Treat as a normal test-quality commit. If reviewing the related MuSig2 implementation, ensure the underlying walletprocesspsbt/finalizepsbt code actually generates fresh random nonces per session, since this test merely observes that behavior.
Security signals we found
MuSig2 nonce reuse is a known cryptographic risk (reusing a nonce with different messages can leak private keys)
The commit adds a regression test, not a fix
No production code paths are changed
Evidence from the diff
The diff modifies test/functional/wallet_musig.py to execute two parallel MuSig2 signing sessions for the same scenario. It adds helper assertions (assert_musig_signer_data, assert_musig_aggregate_in_script) and verifies that pubnonces, partial_sigs, and final script witnesses differ between the two sessions while aggregate pubkeys and participant pubkeys remain equal. The production signing logic is untouched.
Changed components
test/functional/wallet_musig.pyInspect captured patch +52 / −35
diff --git a/test/functional/wallet_musig.py b/test/functional/wallet_musig.py
index 0e3e4377..c392abaf 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:
Why this scored 12/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.