rpc: Fix descriptorprocesspsbt internal bug on invalid signatures
What changed, and why it matters
This commit fixes a bug in Bitcoin Core's descriptorprocesspsbt RPC command. Previously, the command could mark a partially-signed Bitcoin transaction (PSBT) as 'complete' and return a finalized transaction hex even when one of the signatures was actually invalid. The fix makes the command verify signatures cryptographically, not just check that signature data is present. The new test deliberately corrupts a signature and confirms the command now correctly reports the PSBT as incomplete.
Treat this as a correctness/security fix and backport to maintained branches. Wallets or services that rely on descriptorprocesspsbt's 'complete' flag or returned hex should upgrade, because prior versions could produce an unbroadcastable or invalid final transaction. Review other RPCs or code paths that use PSBTInputSigned instead of PSBTInputSignedAndVerified for similar gaps.
Security signals we found
Logic bug allowing invalid signatures to be treated as valid
Missing cryptographic verification before finalization
PSBT completeness check bypass
Regression test added for malicious/corrupted signature
RPC output could mislead callers into broadcasting an invalid transaction
Evidence from the diff
In src/rpc/rawtransaction.cpp, the descriptorprocesspsbt implementation replaced PSBTInputSigned(input) with PSBTInputSignedAndVerified(psbtx, i, &txdata). The old helper only checks whether a PSBT input appears to have final script/witness data, while the new helper validates the signature(s) against the input’s UTXO and the transaction data. PrecomputedTransactionData is now built via PrecomputePSBTData and passed in. A regression test was added in test/functional/rpc_psbt.py that creates a taproot key-path spend PSBT, finalizes it, flips a random bit in the 64/65-byte signature, and asserts descriptorprocesspsbt reports complete=false and does not emit a final hex transaction.
Changed components
src/rpc/rawtransaction.cpp (descriptorprocesspsbt RPC)PSBT signing/finalization logictest/functional/rpc_psbt.pytest/functional/test_framework/util.pyInspect captured patch +53 / −3
diff --git a/src/rpc/rawtransaction.cpp b/src/rpc/rawtransaction.cpp
index 396768ed..5de3b2ab 100644
--- a/src/rpc/rawtransaction.cpp
+++ b/src/rpc/rawtransaction.cpp
@@ -2112,10 +2112,12 @@ RPCMethod descriptorprocesspsbt()
sighash_type,
finalize);
- // Check whether or not all of the inputs are now signed
+ // Check whether or not all of the inputs are now correctly signed
bool complete = true;
- for (const auto& input : psbtx.inputs) {
- complete &= PSBTInputSigned(input);
+ const std::optional<PrecomputedTransactionData> txdata_opt{PrecomputePSBTData(psbtx)};
+ const PrecomputedTransactionData txdata{*CHECK_NONFATAL(txdata_opt)};
+ for (unsigned int i = 0; i < psbtx.inputs.size(); ++i) {
+ complete = complete && PSBTInputSignedAndVerified(psbtx, i, &txdata);
}
DataStream ssTx{};
diff --git a/test/functional/rpc_psbt.py b/test/functional/rpc_psbt.py
index 4703ee3b..016ed8f1 100755
--- a/test/functional/rpc_psbt.py
+++ b/test/functional/rpc_psbt.py
@@ -36,6 +36,7 @@ from test_framework.psbt import (
PSBT_IN_MUSIG2_PUB_NONCE,
PSBT_IN_NON_WITNESS_UTXO,
PSBT_IN_WITNESS_UTXO,
+ PSBT_IN_FINAL_SCRIPTWITNESS,
PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS,
PSBT_OUT_TAP_TREE,
PSBT_OUT_SCRIPT,
@@ -52,6 +53,7 @@ from test_framework.util import (
assert_raises_rpc_error,
find_vout_for_address,
wallet_importprivkey,
+ bitflipper
)
from test_framework.wallet_util import (
calculate_input_weight,
@@ -469,6 +471,47 @@ class PSBTTest(BitcoinTestFramework):
assert_raises_rpc_error(-8, "The PSBT version can only be 2 or 0", self.nodes[0].converttopsbt, hexstring=rawtx, psbt_version=1)
assert_raises_rpc_error(-8, "The PSBT version can only be 2 or 0", self.nodes[0].psbtbumpfee, txid=tobump, psbt_version=1)
+ def test_psbt_with_invalid_signature(self):
+ self.log.info("Test descriptorprocesspsbt with invalid signature in signed PSBT")
+
+ def_wallet = self.nodes[0].get_wallet_rpc(self.default_wallet_name)
+ outputs = [{def_wallet.getnewaddress(address_type="bech32m"): 1}]
+ def_wallet.send(outputs)
+ self.generate(self.nodes[0], 1)
+
+ utxos = [utxo for utxo in def_wallet.listunspent() if utxo["desc"].startswith("tr")]
+ unsigned_psbt = def_wallet.walletcreatefundedpsbt(utxos, [{def_wallet.getnewaddress(): 0.5}])["psbt"]
+ descs = [desc for desc in def_wallet.listdescriptors(True)["descriptors"] if desc["desc"].startswith("tr")]
+
+ # Unload the wallet to avoid using wallet RPC internals in descriptorprocesspsbt.
+ def_wallet.unloadwallet()
+
+ result = self.nodes[0].descriptorprocesspsbt(psbt=unsigned_psbt, descriptors=descs, finalize=True)
+ assert_equal(result["complete"], True)
+ assert_equal("hex" in result, True)
+
+ valid_sig_psbt = result["psbt"]
+ flawed_psbt = PSBT.from_base64(valid_sig_psbt)
+ valid_witness = flawed_psbt.i[0].map.get(PSBT_IN_FINAL_SCRIPTWITNESS)
+ # The witness format is [num_items: CompactSize] [item1_len: CompactSize] [item1_data] ...
+ # For taproot key-path spend [0x01] [0x40 or 0x41] [64 or 65 byte signature].
+ # Skip the first 2 prefix bytes and flip a bit in the signature bytes only.
+ assert_equal(valid_witness[0], 1)
+ prefix = valid_witness[:2]
+ sig_len = valid_witness[1]
+ signature = valid_witness[2:2 + sig_len]
+ invalid_sig = bitflipper(signature)
+ invalid_witness = prefix + invalid_sig + valid_witness[2 + sig_len:]
+ flawed_psbt.i[0].map[PSBT_IN_FINAL_SCRIPTWITNESS] = invalid_witness
+ invalid_sig_psbt = flawed_psbt.to_base64()
+
+ result = self.nodes[0].descriptorprocesspsbt(psbt=invalid_sig_psbt, descriptors=descs, finalize=True)
+ assert_equal(result["complete"], False)
+ assert_equal("hex" in result, False)
+
+ # Load the default wallet back for later test cases.
+ self.nodes[0].loadwallet(self.default_wallet_name)
+
def run_test(self):
# Create and fund a raw tx for sending 10 BTC
psbtx1 = self.nodes[0].walletcreatefundedpsbt([], {self.nodes[2].getnewaddress():10})['psbt']
@@ -1362,6 +1405,7 @@ class PSBTTest(BitcoinTestFramework):
self.test_psbt_named_parameter_handling()
self.test_psbt_roundtrip()
self.test_psbt_version()
+ self.test_psbt_with_invalid_signature()
if __name__ == '__main__':
PSBTTest(__file__).main()
diff --git a/test/functional/test_framework/util.py b/test/functional/test_framework/util.py
index 3e7e6dd9..7217f4af 100644
--- a/test/functional/test_framework/util.py
+++ b/test/functional/test_framework/util.py
@@ -759,3 +759,7 @@ def is_dir_writable(dir_path: pathlib.Path) -> bool:
return True
except OSError:
return False
+
+def bitflipper(input):
+ assert isinstance(input, bytes)
+ return (int.from_bytes(input, "little") ^ (1 << random.randrange(len(input) * 8))).to_bytes(len(input), "little")
Why this scored 62/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.