Merge bitcoin/bitcoin#35269: musig: Include pubnonce in session id

2ef6679c2c test: Check that MuSig2 signing does not reuse nonces (Ava Chow)
bb05986c0a musig: Include pubnonce in session id (Ava Chow)

Pull request description:

  It is safe to have multiple musig signing sessions over the same message so long as the nonces used are different. Including the pubnonce in the session id allows for multiple simultaneous signing sessions over the same message, rather than asserting when the user tries to do this.

  The second commit tests this behavior, both ensuring that there is no crash, and verifying that both sessions produce unique nonces and signatures to verify that no reuse is occurring.

  Lastly, the assertion in `SetMuSig2SecNonce` is retained as hitting it now would indicate that a nonce has been reused. We prefer to assert and crash rather than do something that is highly likely to leak a private key.

  Fixes #35250

ACKs for top commit:
  rkrux:
    lgtm ACK 2ef6679c2c
  junbyjun1238:
    utACK 2ef6679c2c
  theStack:
    ACK 2ef6679c2c

Tree-SHA512: 9fb60b68ebe0ea9656408afb65b9ec9f280632e1bb84a4821b074c8d8569847845f7c29da800c757b9ddf3aa31aa890dd9e3646cf119917a714e7daf20be2198
This commit is contained in:
merge-script
2026-06-02 21:33:01 +02:00
5 changed files with 64 additions and 41 deletions

View File

@@ -125,10 +125,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<uint8_t>& pubnonce)
{
HashWriter hasher;
hasher << script_pubkey << part_pubkey << sighash;
hasher << script_pubkey << part_pubkey << sighash << pubnonce;
return hasher.GetSHA256();
}

View File

@@ -57,7 +57,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<uint8_t>& pubnonce);
std::vector<uint8_t> CreateMuSig2Nonce(MuSig2SecNonce& secnonce, const uint256& sighash, const CKey& our_seckey, const CPubKey& aggregate_pubkey, const std::vector<CPubKey>& pubkeys);
std::optional<uint256> CreateMuSig2PartialSig(const uint256& hash, const CKey& our_seckey, const CPubKey& aggregate_pubkey, const std::vector<CPubKey>& pubkeys, const std::map<CPubKey, std::vector<uint8_t>>& pubnonces, MuSig2SecNonce& secnonce, const std::vector<std::pair<uint256, bool>>& tweaks);

View File

@@ -137,7 +137,7 @@ std::vector<uint8_t> 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;
}
@@ -170,7 +170,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<std::reference_wrapper<MuSig2SecNonce>> secnonce = provider.GetMuSig2SecNonce(session_id);
if (!secnonce || !secnonce->get().IsValid()) return false;

View File

@@ -300,7 +300,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<uint256, MuSig2SecNonce> m_musig2_secnonces;

View File

@@ -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: