From e3d1e75a519a3542a736c3097ce8ab3a2bd8dc5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C5=91rinc?= Date: Wed, 5 Aug 2026 14:41:12 -0700 Subject: [PATCH 1/2] test: characterize MuSig2 derivation aborts Exercise `analyzepsbt` and `finalizepsbt` with mismatched and hardened MuSig2 aggregate derivation paths, restarting the node after each abort so the current behavior remains executable. --- test/functional/rpc_psbt.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/test/functional/rpc_psbt.py b/test/functional/rpc_psbt.py index 3435a4c9728..364a9334511 100755 --- a/test/functional/rpc_psbt.py +++ b/test/functional/rpc_psbt.py @@ -12,6 +12,7 @@ from test_framework.blocktools import ( MAX_STANDARD_TX_WEIGHT, ) from test_framework.descriptors import descsum_create +from test_framework.extendedkey import hardened from test_framework.key import H_POINT from test_framework.messages import ( COutPoint, @@ -38,6 +39,8 @@ from test_framework.psbt import ( PSBT_IN_MUSIG2_PUB_NONCE, PSBT_IN_NON_WITNESS_UTXO, PSBT_IN_PROPRIETARY, + PSBT_IN_TAP_BIP32_DERIVATION, + PSBT_IN_TAP_INTERNAL_KEY, PSBT_IN_WITNESS_UTXO, PSBT_IN_FINAL_SCRIPTWITNESS, PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS, @@ -45,8 +48,8 @@ from test_framework.psbt import ( PSBT_OUT_TAP_TREE, PSBT_OUT_SCRIPT, ) -from test_framework.script import CScript, OP_TRUE, SIGHASH_ALL, SIGHASH_ANYONECANPAY -from test_framework.script_util import MIN_STANDARD_TX_NONWITNESS_SIZE +from test_framework.script import CScript, OP_TRUE, SIGHASH_ALL, SIGHASH_ANYONECANPAY, hash160 +from test_framework.script_util import MIN_STANDARD_TX_NONWITNESS_SIZE, output_key_to_p2tr_script from test_framework.test_framework import BitcoinTestFramework from test_framework.util import ( assert_not_equal, @@ -54,6 +57,7 @@ from test_framework.util import ( assert_equal, assert_greater_than, assert_greater_than_or_equal, + assert_raises, assert_raises_rpc_error, find_vout_for_address, wallet_importprivkey, @@ -300,6 +304,27 @@ class PSBTTest(BitcoinTestFramework): assert "participant_pubkeys" in out_participant_pks assert_equal(out_participant_pks["participant_pubkeys"], [out_pubkey1.hex(), out_pubkey2.hex()]) + def test_musig2_untrusted_derivation(self): + self.log.info("Test MuSig2 aggregate derivation from untrusted PSBT fields") + node = self.nodes[0] + + script_pubkey = bytes.fromhex(H_POINT) + _, aggregate_pubkey = generate_keypair() + _, participant_pubkey = generate_keypair() + + # Both have a matching aggregate fingerprint but cannot derive the script pubkey: 0 derives a different key, hardened(0) cannot be derived at all + for index in [0, hardened(0)]: + psbt = self.create_psbt(inputs={ + PSBT_IN_WITNESS_UTXO: CTxOut(nValue=1, scriptPubKey=output_key_to_p2tr_script(script_pubkey)).serialize(), + bytes([PSBT_IN_TAP_BIP32_DERIVATION]) + script_pubkey: ser_compact_size(0) + hash160(aggregate_pubkey)[:4] + index.to_bytes(4, "little"), + PSBT_IN_TAP_INTERNAL_KEY: script_pubkey, + bytes([PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS]) + aggregate_pubkey: [participant_pubkey], + }).to_base64() + assert_raises(Exception, node.analyzepsbt, psbt) # TODO: Unexpected derivation metadata should not abort the node + self.start_node(0) + assert_raises(Exception, node.finalizepsbt, psbt) # TODO: Unexpected derivation metadata should not abort the node + self.start_node(0) + def test_combinepsbt_preserves_proprietary_fields(self): self.log.info("Test that combining PSBTs preserves proprietary fields") @@ -1463,6 +1488,7 @@ class PSBTTest(BitcoinTestFramework): self.test_psbt_roundtrip() self.test_psbt_version() self.test_psbt_with_invalid_signature() + self.test_musig2_untrusted_derivation() if __name__ == '__main__': PSBTTest(__file__).main() From 73a94b45459a9433ffcf99aff3b044cc7166ee12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C5=91rinc?= Date: Thu, 6 Aug 2026 14:16:08 -0700 Subject: [PATCH 2/2] psbt: avoid aborting on invalid MuSig2 derivations `SignMuSig2()` derives MuSig2 aggregate keys from metadata supplied by a PSBT. A hardened child cannot be publicly derived, and a path that produces another key does not identify the aggregate being signed. Check each child index as `uint32_t` immediately before deriving that child, preserving the hardened bit without an implementation-defined conversion to `int`. Return failure if a child index is hardened, and skip a path if it derives to a different key so another aggregate can still be tried. The descriptor parser enforces the same requirement for MuSig2 aggregate derivations. Co-authored-by: Anthropic Security Co-authored-by: Evan Sultanik Co-authored-by: Ava Chow --- src/script/sign.cpp | 5 +++-- test/functional/rpc_psbt.py | 7 ++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/script/sign.cpp b/src/script/sign.cpp index c4d59de46d5..efee5a35fe0 100644 --- a/src/script/sign.cpp +++ b/src/script/sign.cpp @@ -318,14 +318,15 @@ static bool SignMuSig2(const BaseSignatureCreator& creator, SignatureData& sigda } // Get the BIP32 derivation tweaks CExtPubKey extpub = CreateMuSig2SyntheticXpub(agg_pub); - for (const int i : agg_info.path) { + for (const uint32_t i : agg_info.path) { + if (i >> 31) return false; // Hardened derivation is not possible from a public key auto& [t, xonly] = tweaks.emplace_back(); xonly = false; if (!extpub.Derive(extpub, i, &t)) { return false; } } - Assert(XOnlyPubKey(extpub.pubkey) == script_pubkey); + if (XOnlyPubKey(extpub.pubkey) != script_pubkey) continue; plain_pub = extpub.pubkey; } diff --git a/test/functional/rpc_psbt.py b/test/functional/rpc_psbt.py index 364a9334511..bab3d92f39a 100755 --- a/test/functional/rpc_psbt.py +++ b/test/functional/rpc_psbt.py @@ -57,7 +57,6 @@ from test_framework.util import ( assert_equal, assert_greater_than, assert_greater_than_or_equal, - assert_raises, assert_raises_rpc_error, find_vout_for_address, wallet_importprivkey, @@ -320,10 +319,8 @@ class PSBTTest(BitcoinTestFramework): PSBT_IN_TAP_INTERNAL_KEY: script_pubkey, bytes([PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS]) + aggregate_pubkey: [participant_pubkey], }).to_base64() - assert_raises(Exception, node.analyzepsbt, psbt) # TODO: Unexpected derivation metadata should not abort the node - self.start_node(0) - assert_raises(Exception, node.finalizepsbt, psbt) # TODO: Unexpected derivation metadata should not abort the node - self.start_node(0) + assert_equal(node.analyzepsbt(psbt)["inputs"][0]["is_final"], False) + assert_equal(node.finalizepsbt(psbt)["complete"], False) def test_combinepsbt_preserves_proprietary_fields(self): self.log.info("Test that combining PSBTs preserves proprietary fields")