bip322: label as v2.0.0 and test proof-of-funds without message_challenge
What changed, and why it matters
This commit is a documentation and test update for the Ledger Bitcoin app. It updates references to BIP-322 from an unspecified version to v2.0.0, clarifies that a proof-of-funds message signature must still include the virtual first input, and adds a regression test to confirm the app already rejects malformed requests that omit that virtual input. The actual security check in the device code was already present and is not changed.
No immediate action required; this is a test/documentation commit. Reviewers may want to confirm the existing validator check comprehensively covers all ways a proof-of-funds request could omit or misplace the message_challenge input.
Security signals we found
Regression test added for missing message_challenge in BIP-322 proof-of-funds
Documentation updated to cite BIP-322 v2.0.0 requirement that first input is not optional
Error-code comment clarified to mention missing message_challenge case
No functional C code change to validation logic
Evidence from the diff
The change is defensive hardening-by-test, not a vulnerability fix. The C validator (bip322_validation.c) already required the first input to spend output 0 of the recomputed to_spend transaction, which implicitly rejects proof-of-funds PSBTs whose first input is a real UTXO. The commit updates comments/error messages in error_codes.h, bip322_validation.c/h, doc/bitcoin.md, and CHANGELOG.md to cite BIP-322 v2.0.0, and adds a Python regression test (test_sign_bip322_pof_missing_challenge) that crafts a proof-of-funds PSBT with a zero-value real UTXO at output index 0 as the first input and verifies it fails with EC_SIGN_PSBT_BIP322_TOSPEND_MISMATCH. No validator logic is modified.
Changed components
src/handler/sign_psbt/bip322_validation.csrc/handler/sign_psbt/bip322_validation.hsrc/error_codes.hdoc/bitcoin.mdCHANGELOG.mdtest_utils/bip0322.pytests/test_sign_psbt_bip322.pyInspect captured patch +73 / −31
### CHANGELOG.md
@@ -11,7 +11,7 @@ Dates are in `dd-mm-yyyy` format.
### Added
-- Support for [BIP-0322](https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki) generic signed messages: a PSBT carrying the `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE` global field (0x09) is now verified to have the exact BIP-322 *to_sign* structure and reviewed on-screen as a message signature (account, address and message), instead of being shown as a transaction with an `OP_RETURN` output and no fees. Proof-of-funds requests (additional inputs spending real coins of the account) are supported, with the total proven amount shown in the review. Works with any supported wallet policy, including multisig and miniscript.
+- Support for [BIP-0322](https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki) (v2.0.0) generic signed messages: a PSBT carrying the `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE` global field (0x09) is now verified to have the exact BIP-322 *to_sign* structure and reviewed on-screen as a message signature (account, address and message), instead of being shown as a transaction with an `OP_RETURN` output and no fees. Proof-of-funds requests (additional inputs spending real coins of the account) are supported, with the total proven amount shown in the review. Works with any supported wallet policy, including multisig and miniscript.
### Fixed
### doc/bitcoin.md
@@ -258,7 +258,7 @@ Other values of the `<tag>` are undefined and reserved for future use.
#### BIP-322 message signing
-If the PSBT contains the `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE` global field (`0x09`, defined in [BIP-0322](https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki)), the PSBT is treated as a request to sign the BIP-322 *to_sign* virtual transaction for the message contained in the field, and is reviewed on-screen as a **message signature** (account, address whose ownership is being proven, and the message), never as a transaction. If the message is too long (more than 640 bytes) or not printable ASCII (which includes any UTF-8 text with non-ASCII characters), its plain sha256 hash is shown instead, matching the behavior of the legacy `SIGN_MESSAGE` command (note that this display hash is *not* the BIP-322 tagged hash that the signature commits to).
+If the PSBT contains the `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE` global field (`0x09`, defined in [BIP-0322](https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki), version 2.0.0), the PSBT is treated as a request to sign the BIP-322 *to_sign* virtual transaction for the message contained in the field, and is reviewed on-screen as a **message signature** (account, address whose ownership is being proven, and the message), never as a transaction. If the message is too long (more than 640 bytes) or not printable ASCII (which includes any UTF-8 text with non-ASCII characters), its plain sha256 hash is shown instead, matching the behavior of the legacy `SIGN_MESSAGE` command (note that this display hash is *not* the BIP-322 tagged hash that the signature commits to).
Before showing the message, the app enforces the exact structure mandated by BIP-322, and refuses to sign otherwise:
@@ -269,7 +269,7 @@ Before showing the message, the app enforces the exact structure mandated by BIP
The last rule is the security anchor of the flow: it guarantees that the message shown on-screen is exactly the message committed to by the produced signature, and that the signed transaction is provably unspendable (the *to_spend* transaction's input references the null outpoint).
-**Proof of funds**: any input beyond the first makes the request a BIP-322 *proof of funds*. The additional inputs spend real UTXOs; each one must belong to the wallet policy (external inputs are rejected) and have a sequence that is either 0 or has the [BIP-68](https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki) relative-timelock disable flag set (which includes the final sequence `0xFFFFFFFF`, explicit or implied by an omitted `PSBT_IN_SEQUENCE`). Any other sequence is rejected: with version 2 it would be a relative timelock on *to_sign* (not supported yet); with version 0 it is meaningless. The total amount of the proven coins is shown in the review as an additional "Proving funds" line. The usual UTXO authentication rules apply to these inputs, including the warning for segwitv0 inputs missing the non-witness UTXO. The first input is exempt from that warning, even in a proof of funds: the *to_spend* transaction it spends is entirely recomputed by the app, so its non-witness UTXO is not needed.
+**Proof of funds**: any input beyond the first makes the request a BIP-322 *proof of funds*. The first input must still be the *message_challenge* input spending the recomputed *to_spend* transaction (as clarified in BIP-322 v2.0.0, it is not optional); a request made only of real UTXOs is rejected. The additional inputs spend real UTXOs; each one must belong to the wallet policy (external inputs are rejected) and have a sequence that is either 0 or has the [BIP-68](https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki) relative-timelock disable flag set (which includes the final sequence `0xFFFFFFFF`, explicit or implied by an omitted `PSBT_IN_SEQUENCE`). Any other sequence is rejected: with version 2 it would be a relative timelock on *to_sign* (not supported yet); with version 0 it is meaningless. The total amount of the proven coins is shown in the review as an additional "Proving funds" line. The usual UTXO authentication rules apply to these inputs, including the warning for segwitv0 inputs missing the non-witness UTXO. The first input is exempt from that warning, even in a proof of funds: the *to_spend* transaction it spends is entirely recomputed by the app, so its non-witness UTXO is not needed.
A structurally invalid PSBT carrying the field fails with `SW_INCORRECT_DATA`; valid BIP-322 requests using features that are not yet supported (timelocks, i.e. a non-zero locktime, a non-zero sequence on the first input, or a relative timelock on a proof-of-funds input) fail with `SW_NOT_SUPPORTED`. See `error_codes.h` for the specific error codes.
### src/error_codes.h
@@ -85,7 +85,8 @@
// Therefore, it is not possible to prevent the app from producing invalid BIP-322 signatures.
#define EC_SIGN_PSBT_BIP322_INVALID_STRUCTURE 0x0010
-// The input of the BIP-322 to_sign transaction does not spend the to_spend transaction.
+// The first input of the BIP-322 to_sign transaction does not spend the to_spend transaction.
+// For a proof-of-funds, this includes a missing message_challenge input (BIP-322 v2.0.0).
#define EC_SIGN_PSBT_BIP322_TOSPEND_MISMATCH 0x0011
// BIP-322 requires all signatures to use SIGHASH_ALL (or SIGHASH_DEFAULT for taproot inputs).
### src/handler/sign_psbt/bip322_validation.c
@@ -137,7 +137,9 @@ bool __attribute__((noinline)) validate_bip322_request(dispatcher_context_t *dc,
// The remaining checks apply to the first input, which must adhere to the strict BIP-322
// structure.
- // The first input must spend output 0 of to_spend.
+ // The first input must spend output 0 of to_spend. This holds for a proof-of-funds
+ // too: per BIP-322 v2.0.0, the message_challenge is not optional, so a request made
+ // only of real UTXOs (no virtual input) fails below, on the txid binding.
uint32_t prevout_index;
if (PSBT_FIELD_PRESENT != psbt_get_input_prevout_index(dc, &input_map, &prevout_index) ||
prevout_index != 0) {
### src/handler/sign_psbt/bip322_validation.h
@@ -23,11 +23,12 @@
#include "sign_psbt.h"
// Support for BIP-322 generic signed messages (message signing via SIGN_PSBT).
+// This implements BIP-322 v2.0.0 (2026-06-04).
//
// A PSBT with the PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE field requests signing the BIP-322
-// "to_sign" virtual transaction for the contained message. It can never be broadcast (it spends
-// "to_spend", whose input references the null outpoint, and all produced signatures commit to
-// all inputs), so it is reviewed as a message signature, not as a transaction.
+// "to_sign" virtual transaction for the contained message. It can never be broadcast (its first
+// input spends "to_spend", whose input references the null outpoint, and all produced signatures
+// commit to all inputs), so it is reviewed as a message signature, not as a transaction.
//
// The security anchor is validate_bip322_request(): the to_spend txid is recomputed on-device
// from the message (tagged hash) and the input's scriptPubKey, and must match the input's
@@ -43,9 +44,11 @@
* Must be called after preprocess_inputs() and preprocess_outputs(), and only if
* st->bip322.is_message_signing is true.
*
- * The first input must always spend the recomputed to_spend transaction. Additional inputs make
- * the request a proof-of-funds: they must all belong to the wallet policy, and their total amount
- * is later shown to the user.
+ * The first input must always spend the recomputed to_spend transaction: BIP-322 v2.0.0
+ * clarifies that the message_challenge is not optional in a proof of funds, so a request whose
+ * first input spends a real UTXO is rejected. Additional inputs make the request a
+ * proof-of-funds: they must all belong to the wallet policy, and their total amount is later
+ * shown to the user.
*
* On success, st->bip322.challenge_script contains the scriptPubKey being proven. The to_spend
* input is exempt from the missing_nonwitnessutxo warning in preprocess_inputs() (this function
### test_utils/bip0322.py
@@ -1,15 +1,19 @@
"""Helpers to build and verify BIP-322 message-signing PSBTs for the tests.
BIP-322 defines message signing as signing a virtual "to_sign" transaction that spends a
-virtual "to_spend" transaction committing to the message. The PSBT-based flow (BIP-322 v1.0.0+)
+virtual "to_spend" transaction committing to the message. The PSBT-based flow (BIP-322 v2.0.0)
carries the message in the PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE (0x09) global field, which the
signing device uses to recognize the request and display a message-signing UI.
+
+In a proof of funds, the first input is still the message_challenge (spending to_spend); the
+additional inputs spend real UTXOs. BIP-322 v2.0.0 clarifies that the first input is not
+optional.
"""
import base64
import struct
from hashlib import sha256
-from typing import List
+from typing import List, Tuple
from bitcoin_client.ledger_bitcoin import WalletPolicy
from bitcoin_client.ledger_bitcoin.key import ExtendedKey, KeyOriginInfo
@@ -121,30 +125,42 @@ def build_bip322_pof_psbt(wallet_policy: WalletPolicy,
psbt = build_bip322_psbt(wallet_policy, message,
is_change=is_change, address_index=address_index)
- desc_tmpl = DescriptorTemplate.from_string(wallet_policy.descriptor_template)
-
for amount in utxo_amounts:
- prevout, prevout_n, prevout_is_change, prevout_addr_idx = createFakeWalletTransaction(
- 1, 2, amount, wallet_policy)
-
- txin = CTxIn()
- txin.prevout = COutPoint(prevout.sha256, prevout_n)
- txin.scriptSig = b""
- txin.nSequence = 0
+ txin, psbt_input = build_wallet_utxo_input(wallet_policy, amount)
psbt.tx.vin.append(txin)
+ psbt.inputs.append(psbt_input)
- psbt_input = PartiallySignedInput(0)
- if desc_tmpl.is_segwit():
- psbt_input.witness_utxo = prevout.vout[prevout_n]
- if desc_tmpl.is_legacy() or (desc_tmpl.is_segwit() and not desc_tmpl.is_taproot()):
- psbt_input.non_witness_utxo = prevout
+ return psbt
- fill_inout(wallet_policy, psbt_input, is_change=bool(prevout_is_change),
- address_index=prevout_addr_idx)
- psbt.inputs.append(psbt_input)
+def build_wallet_utxo_input(wallet_policy: WalletPolicy,
+ amount: int,
+ *,
+ n_outputs: int = 2) -> Tuple[CTxIn, PartiallySignedInput]:
+ """Builds a transaction input (with sequence 0) spending a real (fake, wallet-owned) UTXO
+ of the given amount, together with its PSBT input map. The UTXO is one of the n_outputs
+ outputs of a fake transaction; with n_outputs=1, the spent output index is always 0."""
- return psbt
+ desc_tmpl = DescriptorTemplate.from_string(wallet_policy.descriptor_template)
+
+ prevout, prevout_n, prevout_is_change, prevout_addr_idx = createFakeWalletTransaction(
+ 1, n_outputs, amount, wallet_policy)
+
+ txin = CTxIn()
+ txin.prevout = COutPoint(prevout.sha256, prevout_n)
+ txin.scriptSig = b""
+ txin.nSequence = 0
+
+ psbt_input = PartiallySignedInput(0)
+ if desc_tmpl.is_segwit():
+ psbt_input.witness_utxo = prevout.vout[prevout_n]
+ if desc_tmpl.is_legacy() or (desc_tmpl.is_segwit() and not desc_tmpl.is_taproot()):
+ psbt_input.non_witness_utxo = prevout
+
+ fill_inout(wallet_policy, psbt_input, is_change=bool(prevout_is_change),
+ address_index=prevout_addr_idx)
+
+ return txin, psbt_input
def bip322_pof_segwitv0_sighash_all(to_sign_tx: CTransaction,
### tests/test_sign_psbt_bip322.py
@@ -23,6 +23,7 @@
from test_utils.bip0322 import (
build_bip322_psbt,
build_bip322_pof_psbt,
+ build_wallet_utxo_input,
build_to_spend_tx,
bip322_segwitv0_sighash_all,
bip322_pof_segwitv0_sighash_all,
@@ -543,6 +544,25 @@ def test_sign_bip322_pof_external_input(navigator: Navigator, firmware: Firmware
IncorrectDataError, EC_SIGN_PSBT_BIP322_EXTERNAL_INPUTS)
+def test_sign_bip322_pof_missing_challenge(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ # BIP-322 v2.0.0 clarifies that the message_challenge (the first input, spending the
+ # virtual to_spend transaction) is not optional in a proof of funds. A request whose
+ # inputs are all real UTXOs, with no virtual input, must be refused.
+ # The real UTXO used as the first input has zero value and output index 0, so that the
+ # request passes the amount and prevout index checks and is rejected by the to_spend
+ # txid binding itself, rather than by an earlier structural check.
+ psbt = build_bip322_pof_psbt(wallet_wpkh, b"I control these coins", [50_000])
+
+ txin, psbt_input = build_wallet_utxo_input(wallet_wpkh, 0, n_outputs=1)
+ assert txin.prevout.n == 0
+ psbt.tx.vin[0] = txin
+ psbt.inputs[0] = psbt_input
+
+ expect_sign_psbt_error(client, navigator, firmware, test_name, psbt,
+ IncorrectDataError, EC_SIGN_PSBT_BIP322_TOSPEND_MISMATCH)
+
+
def test_sign_bip322_pof_final_sequences(navigator: Navigator, firmware: Firmware,
client: RaggerClient, test_name: str):
# BIP-322 gives a timelock meaning only to the first input's sequence: the proof-of-fundsWhy 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.