musig: Include pubnonce in session id #35269

pull achow101 wants to merge 2 commits into bitcoin:master from achow101:musig2-pubnonce-session-id changing 5 files +64 −41
  1. achow101 commented at 2:14 AM on May 12, 2026: member

    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

  2. DrahtBot commented at 2:14 AM on May 12, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    <!--006a51241073e994b41acfe9ec718e94-->

    Code Coverage & Benchmarks

    For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/35269.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline for information on the review process.

    Type Reviewers
    ACK rkrux

    If your review is incorrectly listed, please copy-paste <code>&lt;!--meta-tag:bot-skip--&gt;</code> into the comment that the bot should ignore.

  3. achow101 force-pushed on May 12, 2026
  4. DrahtBot added the label CI failed on May 12, 2026
  5. DrahtBot commented at 3:08 AM on May 12, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task lint: https://github.com/bitcoin/bitcoin/actions/runs/25709139378/job/75485524765</sub> <sub>LLM reason (✨ experimental): CI failed because the Python lint check (ruff) reported an E703 “unnecessary semicolon” in test/functional/wallet_musig.py (line 280), causing py_lint to exit non-zero.</sub>

    <details><summary>Hints</summary>

    Try to run the tests locally, according to the documentation. However, a CI failure may still happen due to a number of reasons, for example:

    • Possibly due to a silent merge conflict (the changes in this pull request being incompatible with the current code in the target branch). If so, make sure to rebase on the latest commit of the target branch.

    • A sanitizer issue, which can only be found by compiling with the sanitizer and running the affected test.

    • An intermittent issue.

    Leave a comment here, if you need help tracking down a confusing failure.

    </details>

  6. DrahtBot removed the label CI failed on May 12, 2026
  7. in src/musig.h:59 in 11bf7a3774 outdated
      55 | @@ -56,7 +56,7 @@ class MuSig2SecNonce
      56 |      bool IsValid();
      57 |  };
      58 |  
      59 | -uint256 MuSig2SessionID(const CPubKey& script_pubkey, const CPubKey& part_pubkey, const uint256& sighash);
      60 | +uint256 MuSig2SessionID(const CPubKey& script_pubkey, const CPubKey& part_pubkey, const uint256& sighash, const std::vector<uint8_t>& pubnonce);
    


    rkrux commented at 11:53 AM on May 12, 2026:

    In 11bf7a3774c9f0286966f8073a6e2e49dd08c516 "musig: Include pubnonce in session id"

    The comment in scriptpubkeyman.h needs to be updated. Also, not a fan of having this comment here and not in musig.h, which is more likely to have related updates.

    diff --git a/src/wallet/scriptpubkeyman.h b/src/wallet/scriptpubkeyman.h
    index 1bc5249ca8..56ab66ea71 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. It is the SHA256 of aggregate xonly key, participant pubkey, sighash, pubnonce.
          */
         mutable std::map<uint256, MuSig2SecNonce> m_musig2_secnonces;
     
    

    achow101 commented at 6:34 PM on May 12, 2026:

    Updated the comment and moved it to MuSig2SessionID

  8. in src/musig.cpp:125 in 11bf7a3774 outdated
     118 | @@ -119,10 +119,10 @@ bool MuSig2SecNonce::IsValid()
     119 |      return m_impl->IsValid();
     120 |  }
     121 |  
     122 | -uint256 MuSig2SessionID(const CPubKey& script_pubkey, const CPubKey& part_pubkey, const uint256& sighash)
     123 | +uint256 MuSig2SessionID(const CPubKey& script_pubkey, const CPubKey& part_pubkey, const uint256& sighash, const std::vector<uint8_t>& pubnonce)
     124 |  {
     125 |      HashWriter hasher;
     126 | -    hasher << script_pubkey << part_pubkey << sighash;
     127 | +    hasher << script_pubkey << part_pubkey << sighash << pubnonce;
    


    rkrux commented at 12:02 PM on May 12, 2026:

    In 11bf7a3 "musig: Include pubnonce in session id"

    - hasher << script_pubkey << part_pubkey << sighash << pubnonce;
    + hasher << script_pubkey << part_pubkey << pubnonce << sighash;
    

    Nit: Seeing the pubnonce next to the corresponding participant pubkey without the sighash in between is easier on the eyes.


    achow101 commented at 6:35 PM on May 12, 2026:

    I don't think that's necessary.

  9. in test/functional/wallet_musig.py:252 in 0a6ada4ce1
     248 | @@ -248,19 +249,36 @@ def test_success_case(self, comment, pattern, sighash_type=None, scriptpath=Fals
     249 |              assert_equal(len(part_pks), 0)
     250 |  
     251 |          # Add pubnonces
     252 | +        # Run twice to start 2 different signing sessions to verify no nonce reuse
    


    rkrux commented at 1:00 PM on May 12, 2026:
    
    +        # Run two signing sessions simultaneously to verify no nonce reuse
             # Add pubnonces
    -        # Run twice to start 2 different signing sessions to verify no nonce reuse
    

    achow101 commented at 6:35 PM on May 12, 2026:

    Done

  10. in test/functional/wallet_musig.py:280 in 0a6ada4ce1
     279 | +            assert_equal(pn["aggregate_pubkey"], pn2["aggregate_pubkey"])
     280 | +            if "leaf_hash" in pn:
     281 | +                assert_equal(pn["leaf_hash"], pn2["leaf_hash"])
     282 | +            else:
     283 | +                assert "leaf_hash" not in pn2
     284 | +            assert_not_equal(pn["pubnonce"], pn2["pubnonce"])
    


    rkrux commented at 1:05 PM on May 12, 2026:
    1. Extracting out multiple psbts input pubnonces assertion
    2. Using assert_equal(a, b, c) instead of 2 assert_equal calls.
    diff --git a/test/functional/wallet_musig.py b/test/functional/wallet_musig.py
    index 9cc4caff73..4651df4774 100755
    --- a/test/functional/wallet_musig.py
    +++ b/test/functional/wallet_musig.py
    @@ -112,6 +112,34 @@ class WalletMuSigTest(BitcoinTestFramework):
     
             return wallets, psbt
     
    +    def assert_pubnonces_in_psbts_input(self, dec_psbt_1_input, dec_psbt_2_input, expected_pubnonces):
    +        assert_equal(len(dec_psbt_1_input["musig2_pubnonces"]), len(dec_psbt_2_input["musig2_pubnonces"]), expected_pubnonces)
    +        for i in range(0, len(dec_psbt_1_input["musig2_pubnonces"])):
    +            pn = dec_psbt_1_input["musig2_pubnonces"][i]
    +            pn2 = dec_psbt_2_input["musig2_pubnonces"][i]
    +
    +            assert_equal(pn["participant_pubkey"], pn2["participant_pubkey"])
    +            assert_equal(pn["aggregate_pubkey"], pn2["aggregate_pubkey"])
    +            if "leaf_hash" in pn:
    +                assert_equal(pn["leaf_hash"], pn2["leaf_hash"])
    +            else:
    +                assert "leaf_hash" not in pn2
    +            assert_not_equal(pn["pubnonce"], pn2["pubnonce"])
    +
         def test_failure_case_1(self, comment, pat):
             self.log.info(f"Testing {comment}")
             wallets, psbt = self.setup_musig_scenario(pat)
    @@ -264,21 +292,10 @@ class WalletMuSigTest(BitcoinTestFramework):
             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)
    -        assert_equal(len(dec_psbt2["inputs"][0]["musig2_pubnonces"]), expected_pubnonces)
    +        self.assert_pubnonces_in_psbts_input(dec_psbt["inputs"][0], self.nodes[0].decodepsbt(comb_nonce_psbt2)["inputs"][0], expected_pubnonces)
    +
             for i in range(0, len(dec_psbt["inputs"][0]["musig2_pubnonces"])):
                 pn = dec_psbt["inputs"][0]["musig2_pubnonces"][i]
    -            pn2 = dec_psbt2["inputs"][0]["musig2_pubnonces"][i]
    -
    -            assert_equal(pn["participant_pubkey"], pn2["participant_pubkey"])
    -            assert_equal(pn["aggregate_pubkey"], pn2["aggregate_pubkey"])
    -            if "leaf_hash" in pn:
    -                assert_equal(pn["leaf_hash"], pn2["leaf_hash"])
    -            else:
    -                assert "leaf_hash" not in pn2
    -            assert_not_equal(pn["pubnonce"], pn2["pubnonce"])
    -
                 pubkey = pn["aggregate_pubkey"][2:]
                 if "pkh" in pattern or "pk_h" in pattern:
                     pubkey = hash160(bytes.fromhex(pubkey)).hex()
    

    achow101 commented at 6:35 PM on May 12, 2026:

    Made a similar refactor

  11. in test/functional/wallet_musig.py:324 in 0a6ada4ce1
     323 | +            assert_equal(ps["aggregate_pubkey"], ps2["aggregate_pubkey"])
     324 | +            if "leaf_hash" in ps:
     325 | +                assert_equal(ps["leaf_hash"], ps2["leaf_hash"])
     326 | +            else:
     327 | +                assert "leaf_hash" not in ps2
     328 | +            assert_not_equal(ps["partial_sig"], ps2["partial_sig"])
    


    rkrux commented at 1:06 PM on May 12, 2026:
    1. Extracting out multiple psbts input partial sigs assertion
    2. Using assert_equal(a, b, c) instead of 2 assert_equal calls.
    diff --git a/test/functional/wallet_musig.py b/test/functional/wallet_musig.py
    index 9cc4caff73..4651df4774 100755
    --- a/test/functional/wallet_musig.py
    +++ b/test/functional/wallet_musig.py
    @@ -112,6 +112,34 @@ class WalletMuSigTest(BitcoinTestFramework):
     
    +    def assert_partialsigs_in_psbts_input(self, dec_psbt_1_input, dec_psbt_2_input, expected_partial_sigs):
    +        assert_equal(len(dec_psbt_1_input["musig2_partial_sigs"]), len(dec_psbt_2_input["musig2_partial_sigs"]), expected_partial_sigs)
    +        for i in range(0, len(dec_psbt_1_input["musig2_partial_sigs"])):
    +            ps = dec_psbt_1_input["musig2_partial_sigs"][i]
    +            ps2 = dec_psbt_2_input["musig2_partial_sigs"][i]
    +
    +            assert_equal(ps["participant_pubkey"], ps2["participant_pubkey"])
    +            assert_equal(ps["aggregate_pubkey"], ps2["aggregate_pubkey"])
    +            if "leaf_hash" in ps:
    +                assert_equal(ps["leaf_hash"], ps2["leaf_hash"])
    +            else:
    +                assert "leaf_hash" not in ps2
    +            assert_not_equal(ps["partial_sig"], ps2["partial_sig"])
    +
         def test_failure_case_1(self, comment, pat):
             self.log.info(f"Testing {comment}")
             wallets, psbt = self.setup_musig_scenario(pat)
    @@ -308,21 +325,10 @@ class WalletMuSigTest(BitcoinTestFramework):
             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)
    -        assert_equal(len(dec_psbt2["inputs"][0]["musig2_partial_sigs"]), expected_partial_sigs)
    +        self.assert_partialsigs_in_psbts_input(dec_psbt["inputs"][0], self.nodes[0].decodepsbt(comb_psig_psbt2)["inputs"][0], expected_partial_sigs)
    +
             for i in range(0, len(dec_psbt["inputs"][0]["musig2_partial_sigs"])):
                 ps = dec_psbt["inputs"][0]["musig2_partial_sigs"][i]
    -            ps2 = dec_psbt2["inputs"][0]["musig2_partial_sigs"][i]
    -
    -            assert_equal(ps["participant_pubkey"], ps2["participant_pubkey"])
    -            assert_equal(ps["aggregate_pubkey"], ps2["aggregate_pubkey"])
    -            if "leaf_hash" in ps:
    -                assert_equal(ps["leaf_hash"], ps2["leaf_hash"])
    -            else:
    -                assert "leaf_hash" not in ps2
    -            assert_not_equal(ps["partial_sig"], ps2["partial_sig"])
    -
                 pubkey = ps["aggregate_pubkey"][2:]
                 if "pkh" in pattern or "pk_h" in pattern:
                     pubkey = hash160(bytes.fromhex(pubkey)).hex()
    

    achow101 commented at 6:35 PM on May 12, 2026:

    made a similar refactor

  12. in test/functional/wallet_musig.py:344 in 0a6ada4ce1
     338 | @@ -305,8 +339,11 @@ def test_success_case(self, comment, pattern, sighash_type=None, scriptpath=Fals
     339 |  
     340 |          # Non-participant aggregates partial sigs and send
     341 |          finalized = self.nodes[0].finalizepsbt(psbt=comb_psig_psbt, extract=False)
     342 | +        finalized2 = self.nodes[0].finalizepsbt(psbt=comb_psig_psbt2, extract=False)
     343 |          assert_equal(finalized["complete"], True)
     344 | +        assert_equal(finalized2["complete"], True)
    


    rkrux commented at 1:07 PM on May 12, 2026:
    -        assert_equal(finalized["complete"], True)
    -        assert_equal(finalized2["complete"], True)
    +        assert_equal(finalized["complete"], finalized2["complete"], True)
    

    achow101 commented at 6:35 PM on May 12, 2026:

    Done

  13. in test/functional/wallet_musig.py:1 in 0a6ada4ce1 outdated


    rkrux commented at 1:25 PM on May 12, 2026:

    Not in new code but in older code - the checking for aggregate pubkey in both pubnonce and partialsig items is similar flow that can be made a common function.

    diff --git a/test/functional/wallet_musig.py b/test/functional/wallet_musig.py
    index 9cc4caff73..ff067f8079 100755
    --- a/test/functional/wallet_musig.py
    +++ b/test/functional/wallet_musig.py
    @@ -112,6 +112,59 @@ class WalletMuSigTest(BitcoinTestFramework):
    +    def assert_pubkey_in_musig_items(self, dec_psbt_input, pubkeyhash=False, pubnonce=False, partialsig=False):
    +        assert_equal(pubnonce ^ partialsig, True) # Either of both should be True
    +        if pubnonce:
    +            items = dec_psbt_input["musig2_pubnonces"]
    +            error_key = "pubnonce"
    +        else:
    +            items = dec_psbt_input["musig2_partial_sigs"]
    +            error_key = "partial sig"
    +
    +        for i in range(0, len(items)):
    +            item = items[i]
    +            pubkey = item["aggregate_pubkey"][2:]
    +            if pubkeyhash:
    +                pubkey = hash160(bytes.fromhex(pubkey)).hex()
    +            if pubkey in dec_psbt_input["witness_utxo"]["scriptPubKey"]["hex"]:
    +                continue
    +            elif "taproot_scripts" in dec_psbt_input:
    +                for leaf_scripts in dec_psbt_input["taproot_scripts"]:
    +                    if pubkey in leaf_scripts["script"]:
    +                        break
    +                else:
    +                    assert False, f"Aggregate pubkey for {error_key} not seen as output key or in any scripts"
    +            else:
    +                assert False, f"Aggregate pubkey for {error_key} 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)
    @@ -264,34 +317,8 @@ class WalletMuSigTest(BitcoinTestFramework):
             comb_nonce_psbt2 = self.nodes[0].combinepsbt(nonce_psbts2)
     
             dec_psbt = self.nodes[0].decodepsbt(comb_nonce_psbt)
    -        assert_equal(len(dec_psbt["inputs"][0]["musig2_pubnonces"]), expected_pubnonces)
    -        for i in range(0, len(dec_psbt["inputs"][0]["musig2_pubnonces"])):
    -            pn = dec_psbt["inputs"][0]["musig2_pubnonces"][i]
    -            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"
    +        self.assert_pubkey_in_musig_items(dec_psbt["inputs"][0], "pkh" in pattern or "pk_h" in pattern, True, False)
     
             # Add partial sigs
             psig_psbts = []
    @@ -308,40 +335,13 @@ class WalletMuSigTest(BitcoinTestFramework):
             comb_psig_psbt2 = self.nodes[0].combinepsbt(psig_psbts2)
     
             dec_psbt = self.nodes[0].decodepsbt(comb_psig_psbt)
    -        assert_equal(len(dec_psbt["inputs"][0]["musig2_partial_sigs"]), expected_partial_sigs)
    -        for i in range(0, len(dec_psbt["inputs"][0]["musig2_partial_sigs"])):
    -            ps = dec_psbt["inputs"][0]["musig2_partial_sigs"][i]
    -            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"
    +        self.assert_pubkey_in_musig_items(dec_psbt["inputs"][0], "pkh" in pattern or "pk_h" in pattern, False, True)
    

    achow101 commented at 6:35 PM on May 12, 2026:

    made a similar refactor

  14. rkrux commented at 1:31 PM on May 12, 2026: contributor

    A separate code review for 0a6ada4ce181d46f35b0d34d459b249578ed7ce7

    Looking at the musig functional test after some time, I realise that it has become quite long and hard to follow given that different kind of scenarios and values are checked. This PR is an opportunity to refactor it a bit.

  15. rkrux commented at 1:41 PM on May 12, 2026: contributor

    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.

    I was about to raise a PR replacing that assert with a checknotfatal that would make the RPC throw an error in which it could be mentioned that the wallet needs to be unloaded and reloaded for a fresh musig signing flow to be started - but that's not the best user experience.

  16. 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.
    bb05986c0a
  17. achow101 force-pushed on May 12, 2026
  18. in test/functional/wallet_musig.py:138 in 76eae3d6bd
     133 | +                if pubkey in leaf_scripts["script"]:
     134 | +                    break
     135 | +            else:
     136 | +                assert False, "Aggregate pubkey for pubnonce not seen as output key, or in any scripts"
     137 | +        else:
     138 | +            assert False, "Aggregate pubkey for pubnonce not seen as output key or internal key"
    


    rkrux commented at 10:39 AM on May 13, 2026:

    for pubnonce

    Should be "partialsig" when musig2_partial_sigs are passed - in the second call of this function.


    achow101 commented at 5:27 PM on May 13, 2026:

    Fixed

  19. rkrux commented at 10:42 AM on May 13, 2026: contributor

    Review 76eae3d6bd63b09eb2773b55b4d303c7e08648c6

    A small string fix is needed, rest is lgtm. Thanks for adding the suggestions.

  20. 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.
    2ef6679c2c
  21. in test/functional/wallet_musig.py:117 in 76eae3d6bd
     111 | @@ -111,6 +112,31 @@ def setup_musig_scenario(self, pat):
     112 |  
     113 |          return wallets, psbt
     114 |  
     115 | +    def assert_musig_signer_data(self, first, second, different_key):
     116 | +        assert_equal(first["participant_pubkey"], second["participant_pubkey"])
     117 | +        assert_equal(second["aggregate_pubkey"], second["aggregate_pubkey"])
    


    junbyjun1238 commented at 1:46 PM on May 13, 2026:

    Nit: this compares second["aggregate_pubkey"] against itself — should be first["aggregate_pubkey"] here to match the checks above and below?


    rkrux commented at 1:51 PM on May 13, 2026:

    Yes, this needs to be fixed. I forgot to use range-diff in the re-review.


    achow101 commented at 5:27 PM on May 13, 2026:

    Fixed

  22. achow101 force-pushed on May 13, 2026
  23. rkrux approved
  24. rkrux commented at 10:06 AM on May 15, 2026: contributor

    lgtm ACK 2ef6679c2ca87bbe305f45ff3df3d19ec3f60595

    This is a better user experience when signing for MuSig compared to asking the user to restart the node or reload the wallet - in case the first signing session needs to be discarded for whatever reason.

    Thanks for taking on the refactoring of the musig functional test as well.


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-05-19 06:51 UTC