sign_psbt: review BIP-322 message signing requests as messages
What changed, and why it matters
This commit adds support for signing Bitcoin messages using the BIP-322 standard inside Ledger's Bitcoin app. Instead of reviewing these as regular money transfers, the device now shows them as message signatures. The change includes safety checks to make sure the signed data is a valid BIP-322 request, cannot be spent as a real transaction, and is tied to the exact message shown to the user. It also blocks this feature when the app is being used in a cryptocurrency swap.
Review the BIP-322 validation logic for completeness, especially edge cases around sequence handling for version 0 vs version 2 transactions, proof-of-funds input validation, and the message streaming/display path. Consider fuzzing or formal review of validate_bip322_request() and load_bip322_message().
Security signals we found
New validation logic for BIP-322 message signing PSBTs
On-device recomputation of to_spend txid to bind signature to message
Rejection of non-SIGHASH_ALL/DEFAULT sighash types for BIP-322
Rejection of timelocked BIP-322 variants (locktime and first input sequence must be 0)
Proof-of-funds inputs restricted to wallet policy inputs only
BIP-322 signing disabled during swap/exchange flows
Message display uses authenticated streaming from committed PSBT global map
Non-witness UTXO warning exemption only for the virtual to_spend input
Evidence from the diff
The patch implements BIP-322 generic signed message support via the SIGN_PSBT handler. It detects the PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE (0x09) global field, hashes the message with the BIP-322 tagged hash, validates the to_sign transaction structure, recomputes the to_spend txid on-device to bind the signature to the message, enforces SIGHASH_ALL/DEFAULT, rejects timelocked variants, and reviews the request as a message signature rather than a transaction. Proof-of-funds inputs must belong to the wallet policy. The feature is disabled during swap operations.
Changed components
src/handler/sign_psbt.csrc/handler/sign_psbt.hsrc/handler/sign_psbt/bip322_validation.csrc/handler/sign_psbt/bip322_validation.hsrc/handler/sign_psbt/init_global_state.csrc/handler/sign_psbt/preprocess_inputs.csrc/handler/sign_psbt/transaction_display.csrc/handler/sign_psbt/transaction_display.hsrc/common/psbt.hsrc/error_codes.hInspect captured patch +556 / −9
### src/common/psbt.h
@@ -10,6 +10,7 @@ enum PsbtGlobalType {
PSBT_GLOBAL_INPUT_COUNT = 0x04,
PSBT_GLOBAL_OUTPUT_COUNT = 0x05,
PSBT_GLOBAL_TX_MODIFIABLE = 0x06,
+ PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE = 0x09,
PSBT_GLOBAL_VERSION = 0xFB,
PSBT_GLOBAL_PROPRIETARY = 0xFC
};
### src/error_codes.h
@@ -79,6 +79,29 @@
// - a PSBT_IN_REQUIRED_TIME_LOCKTIME must be at least 500000000.
#define EC_SIGN_PSBT_REQUIRED_LOCKTIME_OUT_OF_RANGE 0x000f
+// The PSBT has the PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE field, but the transaction does not have
+// the structure mandated by BIP-322 for a to_sign transaction.
+// Note: the app cannot verify whether the additional inputs of a proof of funds spend real coins.
+// 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.
+#define EC_SIGN_PSBT_BIP322_TOSPEND_MISMATCH 0x0011
+
+// BIP-322 requires all signatures to use SIGHASH_ALL (or SIGHASH_DEFAULT for taproot inputs).
+#define EC_SIGN_PSBT_BIP322_FORBIDDEN_SIGHASH 0x0012
+
+// The PSBT uses unsupported features (non-zero locktime, non-zero sequence of the first input,
+// or a relative timelock on a proof-of-funds input).
+#define EC_SIGN_PSBT_BIP322_UNSUPPORTED 0x0013
+
+// BIP-322 message signing is not allowed when called from the Exchange app.
+#define EC_SIGN_PSBT_BIP322_NOT_ALLOWED_IN_SWAP 0x0014
+
+// All the inputs of a BIP-322 proof-of-funds must belong to the wallet policy: the total
+// proven amount shown to the user must be trustworthy, and external inputs cannot be signed.
+#define EC_SIGN_PSBT_BIP322_EXTERNAL_INPUTS 0x0015
+
/**
* Swap
*/
### src/handler/sign_psbt.c
@@ -19,6 +19,7 @@
#include <string.h>
#include "sign_psbt.h"
+#include "sign_psbt/bip322_validation.h"
#include "sign_psbt/init_global_state.h"
#include "sign_psbt/preprocess_inputs.h"
#include "sign_psbt/preprocess_outputs.h"
@@ -34,6 +35,7 @@
#include "constants.h"
#include "display.h"
#include "dispatcher.h"
+#include "error_codes.h"
#include "handle_swap_sign_transaction.h"
#include "musig_sessions.h"
#include "sign_psbt_cache.h"
@@ -55,6 +57,18 @@ void handler_sign_psbt(dispatcher_context_t *dc, uint8_t protocol_version) {
// read APDU inputs, initialize global state and read global PSBT map
if (!init_global_state(dc, &st)) return;
+#ifdef HAVE_SWAP
+ if (G_called_from_swap && st.bip322.is_message_signing) {
+ PRINTF("BIP-322 message signing is not allowed during swap\n");
+ SEND_SW_EC(dc, SW_NOT_SUPPORTED, EC_SIGN_PSBT_BIP322_NOT_ALLOWED_IN_SWAP);
+ return;
+ }
+#endif /* HAVE_SWAP */
+
+ if (st.bip322.is_message_signing) {
+ ui_set_processing_screen_text(GA_LOADING_MESSAGE);
+ }
+
sign_psbt_cache_t cache;
init_sign_psbt_cache(&cache);
@@ -83,6 +97,14 @@ void handler_sign_psbt(dispatcher_context_t *dc, uint8_t protocol_version) {
*/
if (!preprocess_outputs(dc, &st, &cache, internal_outputs)) return;
+ /** BIP-322 STRUCTURAL VALIDATION
+ *
+ * If the PSBT declares itself as a BIP-322 message signing request, enforce the exact
+ * to_sign structure; the PSBT is never reviewed as a transaction once the field is present,
+ * but it shares the same signing flow.
+ */
+ if (st.bip322.is_message_signing && !validate_bip322_request(dc, &st)) return;
+
// check if we're only executing the MuSig2 Round 1
bool only_signing_for_musig = true;
for (size_t i = 0; i < st.account.n_internal_key_expressions; i++) {
@@ -130,11 +152,20 @@ void handler_sign_psbt(dispatcher_context_t *dc, uint8_t protocol_version) {
} else
#endif /* HAVE_SWAP */
{
- /** TRANSACTION CONFIRMATION
- *
- * Display each non-change output, and transaction fees, and acquire user confirmation,
- */
- if (!display_transaction(dc, &st, internal_outputs)) return;
+ if (st.bip322.is_message_signing) {
+ /** BIP-322 MESSAGE CONFIRMATION
+ *
+ * Review as a message signature (account, address, message).
+ */
+ if (!display_bip322_message(dc, &st)) return;
+ } else {
+ /** TRANSACTION CONFIRMATION
+ *
+ * Display each non-change output, and transaction fees, and acquire user
+ * confirmation,
+ */
+ if (!display_transaction(dc, &st, internal_outputs)) return;
+ }
}
// Signing always takes some time, so we rather not wait before showing the spinner
@@ -151,7 +182,11 @@ void handler_sign_psbt(dispatcher_context_t *dc, uint8_t protocol_version) {
if (!G_called_from_swap)
#endif /* HAVE_SWAP */
{
- ui_post_processing_confirm_transaction(dc, sign_result);
+ if (st.bip322.is_message_signing) {
+ ui_post_processing_confirm_message(dc, sign_result);
+ } else {
+ ui_post_processing_confirm_transaction(dc, sign_result);
+ }
}
if (!sign_result) {
### src/handler/sign_psbt.h
@@ -107,6 +107,21 @@ typedef struct {
uint8_t tapleaf_hash[32];
} keyexpr_info_t;
+// State for BIP-322 message signing, used when the PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE field
+// is present in the PSBT. The message itself is not stored in the signing state, and rather
+// streamed from the client when needed.
+typedef struct {
+ bool is_message_signing; // true iff PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE is present
+ bool message_printable; // short enough and printable ASCII => shown in full
+ uint64_t message_length;
+ uint8_t message_hash[32]; // BIP0322-signed-message tagged hash of the message
+ uint8_t message_sha256[32]; // plain sha256(message), shown when not printable
+ // the scriptPubKey whose ownership is being proven (BIP-322's message_challenge);
+ // copied at validation time as it is needed again to show the address at display time
+ uint8_t challenge_script[MAX_PREVOUT_SCRIPTPUBKEY_LEN];
+ size_t challenge_script_len;
+} bip322_state_t;
+
// Cache for partial hashes during signing (avoid quadratic hashing for segwit transactions)
typedef struct tx_hashes_s {
uint8_t sha_prevouts[32];
@@ -212,4 +227,7 @@ typedef struct {
tx_ux_warning_t warnings;
+ // BIP-322 message signing state; bip322.is_message_signing is false for normal transactions.
+ bip322_state_t bip322;
+
} sign_psbt_state_t;
### src/handler/sign_psbt/bip322_validation.c
@@ -0,0 +1,200 @@
+/*****************************************************************************
+ * Ledger App Bitcoin.
+ * (c) 2026 Ledger SAS.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *****************************************************************************/
+
+#include <stdint.h>
+#include <string.h>
+
+#include "bip322_validation.h"
+
+/* Local headers */
+#include "amount_from_psbt.h"
+#include "bip322.h"
+#include "constants.h"
+#include "error_codes.h"
+#include "get_merkleized_map.h"
+#include "psbt_fields.h"
+#include "script.h"
+#include "sw.h"
+
+// BIP-68: a sequence with this flag set has no relative timelock semantics.
+#define BIP68_SEQUENCE_LOCKTIME_DISABLE_FLAG (1u << 31)
+
+bool __attribute__((noinline)) validate_bip322_request(dispatcher_context_t *dc,
+ sign_psbt_state_t *st) {
+ LOG_PROCESSOR(__FILE__, __LINE__, __func__);
+
+ // The to_sign transaction must have exactly one output: a zero-value bare OP_RETURN.
+ // preprocess_outputs() cached it as the first (and only) external output; the read below
+ // relies on the first external output always being cached.
+ _Static_assert(N_CACHED_EXTERNAL_OUTPUTS >= 1, "the first external output must be cached");
+ if (st->n_outputs != 1 || st->n_external_outputs != 1 || st->outputs.n_change != 0 ||
+ st->outputs.total_amount != 0 || st->outputs.output_script_lengths[0] != 1 ||
+ st->outputs.output_scripts[0][0] != OP_RETURN) {
+ PRINTF("BIP-322: output is not a single zero-value bare OP_RETURN\n");
+ SEND_SW_EC(dc, SW_INCORRECT_DATA, EC_SIGN_PSBT_BIP322_INVALID_STRUCTURE);
+ return false;
+ }
+
+ // BIP-322 upgradeable rules: the transaction version must be 0 or 2.
+ // The total input amount (zero for a plain message signing; the sum of the proven coins
+ // for a proof-of-funds) is shown to the user, so it must pass the same sanity bound used
+ // elsewhere.
+ if ((st->tx_version != 0 && st->tx_version != 2) ||
+ st->inputs_total_amount > BITCOIN_TOTAL_SUPPLY) {
+ PRINTF("BIP-322: invalid transaction version or input amount\n");
+ SEND_SW_EC(dc, SW_INCORRECT_DATA, EC_SIGN_PSBT_BIP322_INVALID_STRUCTURE);
+ return false;
+ }
+
+ // BIP-322 required rules: all signatures use SIGHASH_ALL (or SIGHASH_DEFAULT for taproot).
+ if (st->warnings.non_default_sighash) {
+ PRINTF("BIP-322: only SIGHASH_ALL or SIGHASH_DEFAULT are allowed\n");
+ SEND_SW_EC(dc, SW_INCORRECT_DATA, EC_SIGN_PSBT_BIP322_FORBIDDEN_SIGHASH);
+ return false;
+ }
+
+ // Timelocked BIP-322 signatures are not yet supported.
+ if (st->locktime != 0) {
+ PRINTF("BIP-322: timelocks are not supported\n");
+ SEND_SW_EC(dc, SW_NOT_SUPPORTED, EC_SIGN_PSBT_BIP322_UNSUPPORTED);
+ return false;
+ }
+
+ // Any input beyond the first makes this a proof-of-funds. Every input must then belong to
+ // the wallet policy: the total proven amount shown to the user must be trustworthy, and
+ // external inputs could not be signed anyway.
+ if (st->warnings.external_inputs) {
+ PRINTF("BIP-322: all inputs must belong to the wallet policy\n");
+ SEND_SW_EC(dc, SW_INCORRECT_DATA, EC_SIGN_PSBT_BIP322_EXTERNAL_INPUTS);
+ return false;
+ }
+
+ for (unsigned int cur_input_index = 0; cur_input_index < st->n_inputs; cur_input_index++) {
+ merkleized_map_commitment_t input_map;
+ if (0 > call_get_merkleized_map(dc,
+ st->inputs_root,
+ st->n_inputs,
+ cur_input_index,
+ &input_map)) {
+ SEND_SW(dc, SW_INCORRECT_DATA);
+ return false;
+ }
+
+ // Sequence rules. BIP-322 gives a timelock meaning only to the sequence of the first
+ // input (the "age" of the signature, together with nLockTime); the proof-of-funds
+ // inputs are ordinary spends of real coins.
+ // A missing PSBT_IN_SEQUENCE means the final sequence number (0xFFFFFFFF) per BIP-370.
+ uint32_t sequence;
+ psbt_field_status_t sequence_status = psbt_get_input_sequence(dc, &input_map, &sequence);
+ if (sequence_status == PSBT_FIELD_ERROR) {
+ SEND_SW(dc, SW_INCORRECT_DATA);
+ return false;
+ }
+ if (cur_input_index == 0) {
+ // The first input must have an explicit sequence of 0: any other value makes this
+ // a timelocked variant, which is not supported yet.
+ if (sequence_status != PSBT_FIELD_PRESENT || sequence != 0) {
+ PRINTF("BIP-322: timelocked variants are not supported (first input's sequence)\n");
+ SEND_SW_EC(dc, SW_NOT_SUPPORTED, EC_SIGN_PSBT_BIP322_UNSUPPORTED);
+ return false;
+ }
+ } else {
+ // A proof-of-funds input may have sequence 0 (the value BIP-322 expects) or any
+ // sequence with the BIP-68 relative-timelock disable flag set, which includes the
+ // final sequence number (explicit, or implied by a missing PSBT_IN_SEQUENCE). With
+ // version 2, any other value would impose a relative timelock on to_sign, which is
+ // again a timelocked variant; with version 0, it is meaningless, so rejected too.
+ if (sequence_status == PSBT_FIELD_ABSENT) {
+ sequence = 0xFFFFFFFF;
+ }
+ if (sequence != 0 && (sequence & BIP68_SEQUENCE_LOCKTIME_DISABLE_FLAG) == 0) {
+ PRINTF("BIP-322: relative timelocks on proof-of-funds inputs are not supported\n");
+ SEND_SW_EC(dc, SW_NOT_SUPPORTED, EC_SIGN_PSBT_BIP322_UNSUPPORTED);
+ return false;
+ }
+ }
+
+ if (cur_input_index != 0) {
+ // Additional (proof-of-funds) inputs spend real UTXOs of the wallet policy; their
+ // amounts and scripts were already verified and aggregated by preprocess_inputs().
+ continue;
+ }
+
+ // 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.
+ uint32_t prevout_index;
+ if (PSBT_FIELD_PRESENT != psbt_get_input_prevout_index(dc, &input_map, &prevout_index) ||
+ prevout_index != 0) {
+ PRINTF("BIP-322: the input does not spend the first output of to_spend\n");
+ SEND_SW_EC(dc, SW_INCORRECT_DATA, EC_SIGN_PSBT_BIP322_INVALID_STRUCTURE);
+ return false;
+ }
+
+ uint8_t prevout_txid[32];
+ if (PSBT_FIELD_PRESENT != psbt_get_input_prevout_txid(dc, &input_map, prevout_txid)) {
+ SEND_SW(dc, SW_INCORRECT_DATA);
+ return false;
+ }
+
+ uint64_t amount;
+ uint8_t challenge_script[MAX_PREVOUT_SCRIPTPUBKEY_LEN];
+ size_t challenge_script_len;
+ if (0 > get_amount_scriptpubkey_from_psbt(dc,
+ &input_map,
+ &amount,
+ challenge_script,
+ &challenge_script_len)) {
+ SEND_SW(dc, SW_INCORRECT_DATA);
+ return false;
+ }
+
+ // The virtual to_spend output has zero value; only the additional (real) inputs may
+ // contribute to the proven amount for proofs of funds.
+ if (amount != 0) {
+ PRINTF("BIP-322: the to_spend output must have zero value\n");
+ SEND_SW_EC(dc, SW_INCORRECT_DATA, EC_SIGN_PSBT_BIP322_INVALID_STRUCTURE);
+ return false;
+ }
+
+ // The security anchor: the input's prevout txid must equal the txid of the to_spend
+ // transaction recomputed from the message hash and the input's own scriptPubKey. This
+ // binds any produced signatures to the to_spend transaction, which contains both the
+ // message (binding it to the signature), and non-existing coins (proving that signatures
+ // are for an unspendable transaction).
+ uint8_t expected_txid[32];
+ if (0 > bip322_compute_to_spend_txid(st->bip322.message_hash,
+ challenge_script,
+ challenge_script_len,
+ expected_txid)) {
+ SEND_SW_EC(dc, SW_INCORRECT_DATA, EC_SIGN_PSBT_BIP322_INVALID_STRUCTURE);
+ return false;
+ }
+
+ if (memcmp(expected_txid, prevout_txid, sizeof(expected_txid)) != 0) {
+ PRINTF("BIP-322: the input does not spend to_spend for this message\n");
+ SEND_SW_EC(dc, SW_INCORRECT_DATA, EC_SIGN_PSBT_BIP322_TOSPEND_MISMATCH);
+ return false;
+ }
+
+ memcpy(st->bip322.challenge_script, challenge_script, challenge_script_len);
+ st->bip322.challenge_script_len = challenge_script_len;
+ }
+
+ return true;
+}
### src/handler/sign_psbt/bip322_validation.h
@@ -0,0 +1,57 @@
+/*****************************************************************************
+ * Ledger App Bitcoin.
+ * (c) 2026 Ledger SAS.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *****************************************************************************/
+
+#pragma once
+
+#include <stdbool.h>
+
+#include "dispatcher.h"
+#include "sign_psbt.h"
+
+// Support for BIP-322 generic signed messages (message signing via SIGN_PSBT).
+//
+// 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.
+//
+// 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
+// prevout. This guarantees that the displayed message is exactly the one committed to by the
+// signature, and that the signed transaction is provably unspendable.
+//
+// Primitives (message hash, to_spend) are in common/bip322.h. Other steps live in the SIGN_PSBT
+// steps they belong to: init_global_state() detects the request and hashes the message, and
+// display_bip322_message() reviews it.
+
+/**
+ * Validates that the PSBT follows the structure mandated by BIP-322 for a to_sign transaction.
+ * 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.
+ *
+ * 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
+ * rejects the request unless it spends the recomputed to_spend), so that warning can only concern
+ * the additional inputs of a proof-of-funds.
+ *
+ * Returns true on success; returns false and sends an error status word on failure.
+ */
+bool validate_bip322_request(dispatcher_context_t *dc, sign_psbt_state_t *st);
### src/handler/sign_psbt/init_global_state.c
@@ -20,6 +20,7 @@
#include <string.h>
#include "init_global_state.h"
+#include "bip322.h"
/* SDK headers */
#include "crypto_helpers.h"
@@ -33,6 +34,7 @@
#include "compare_wallet_script_at_path.h"
#include "constants.h"
#include "crypto.h"
+#include "display.h"
#include "dispatcher.h"
#include "error_codes.h"
#include "get_merkle_leaf_element.h"
@@ -43,6 +45,7 @@
#include "psbt.h"
#include "psbt_fields.h"
#include "sign_psbt_cache.h"
+#include "stream_merkleized_map_value.h"
#include "sw.h"
#include "wallet.h"
@@ -113,6 +116,23 @@ static bool __attribute__((noinline)) parse_sign_psbt_apdu(dispatcher_context_t
return true;
}
+static void global_map_keys_callback(dispatcher_context_t *dc,
+ void *callback_state,
+ const merkleized_map_commitment_t *map_commitment,
+ int index,
+ buffer_t *data) {
+ UNUSED(dc);
+ UNUSED(map_commitment);
+ UNUSED(index);
+
+ sign_psbt_state_t *st = (sign_psbt_state_t *) callback_state;
+ // PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE has no keydata: the key is exactly the key type
+ if (data->size - data->offset == 1 &&
+ data->ptr[data->offset] == PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE) {
+ st->bip322.is_message_signing = true;
+ }
+}
+
/**
* Verifies the integrity of the PSBT global map (already committed to by
* st->global_map) and extracts the transaction-wide fields from it
@@ -125,7 +145,13 @@ static bool __attribute__((noinline)) process_global_map(dispatcher_context_t *d
sign_psbt_state_t *st) {
// Check integrity of the global map (this also marks it as validated, so that its values may
// be read by key below).
- if (call_check_merkleized_map_sorted(dc, &st->global_map) < 0) {
+ // This also walks over all keys in the global map, keeping track of any fields
+ // we care about tracking (currently, only the presence of a BIP-322 signature request)
+ st->bip322.is_message_signing = false;
+ if (call_check_merkleized_map_sorted_with_callback(dc,
+ &st->global_map,
+ st,
+ global_map_keys_callback) < 0) {
SEND_SW(dc, SW_INCORRECT_DATA);
return false;
}
@@ -236,6 +262,70 @@ static bool __attribute__((noinline)) load_wallet_account(dispatcher_context_t *
return true;
}
+// State for the message-hashing streaming pass.
+typedef struct {
+ cx_sha256_t tagged_hash_context; // BIP0322-signed-message tagged hash
+ cx_sha256_t sha256_context; // plain sha256, for display of long/unprintable messages
+ bool printable;
+} msg_hash_state_t;
+
+static void message_hash_callback(buffer_t *data, void *cb_state) {
+ msg_hash_state_t *state = (msg_hash_state_t *) cb_state;
+ const uint8_t *bytes = data->ptr + data->offset;
+ size_t len = data->size - data->offset;
+
+ crypto_hash_update(&state->tagged_hash_context.header, bytes, len);
+ crypto_hash_update(&state->sha256_context.header, bytes, len);
+
+ for (size_t i = 0; i < len; i++) {
+ // Line Feed (LF) character is handled by NBGL - let's allow it
+ if ((bytes[i] < 0x20 || bytes[i] > 0x7E) && bytes[i] != '\n') {
+ state->printable = false;
+ }
+ }
+}
+
+/**
+ * Streams the message in the PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE field from the client,
+ * computing its BIP-322 tagged hash, its plain sha256 (used for display when the message is too
+ * long or not printable), and whether it is printable; the results are stored in st->bip322.
+ *
+ * Returns true on success; returns false and sends an error status word on failure.
+ */
+static bool load_bip322_message(dispatcher_context_t *dc, sign_psbt_state_t *st) {
+ LOG_PROCESSOR(__FILE__, __LINE__, __func__);
+
+ uint8_t key[] = {PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE};
+
+ // Stream the message, computing its hashes and printability.
+ msg_hash_state_t hash_state;
+ bip322_message_hash_init(&hash_state.tagged_hash_context);
+ cx_sha256_init(&hash_state.sha256_context);
+ hash_state.printable = true;
+
+ int message_length = call_stream_merkleized_map_value(dc,
+ &st->global_map,
+ key,
+ sizeof(key),
+ NULL,
+ message_hash_callback,
+ &hash_state);
+ if (message_length < 0) {
+ PRINTF("Failed to stream the BIP-322 message\n");
+ SEND_SW(dc, SW_INCORRECT_DATA);
+ return false;
+ }
+
+ st->bip322.message_length = (uint64_t) message_length;
+ st->bip322.message_printable =
+ hash_state.printable && message_length <= MAX_DISPLAYBLE_MESSAGE_LENGTH;
+
+ crypto_hash_digest(&hash_state.tagged_hash_context.header, st->bip322.message_hash, 32);
+ crypto_hash_digest(&hash_state.sha256_context.header, st->bip322.message_sha256, 32);
+
+ return true;
+}
+
bool __attribute__((noinline)) init_global_state(dispatcher_context_t *dc, sign_psbt_state_t *st) {
LOG_PROCESSOR(__FILE__, __LINE__, __func__);
@@ -246,6 +336,9 @@ bool __attribute__((noinline)) init_global_state(dispatcher_context_t *dc, sign_
if (!process_global_map(dc, st)) return false;
+ // for a BIP-322 message signing request, load the message hashes
+ if (st->bip322.is_message_signing && !load_bip322_message(dc, st)) return false;
+
if (!load_wallet_account(dc, st, wallet_id, wallet_hmac)) return false;
st->master_key_fingerprint = crypto_get_master_key_fingerprint();
### src/handler/sign_psbt/preprocess_inputs.c
@@ -341,8 +341,12 @@ bool __attribute__((noinline)) preprocess_inputs(
}
// For segwitv0 inputs, the non-witness utxo _should_ be present; we show a warning
- // to the user otherwise, but we continue nonetheless on approval
- if (segwit_version == 0 && !input.has_nonWitnessUtxo) {
+ // to the user otherwise, but we continue nonetheless on approval.
+ // The first input of a BIP-322 message signing request is exempt: it spends the virtual
+ // to_spend transaction, which validate_bip322_request() recomputes on-device and requires
+ // to match the input's prevout (aborting otherwise), so its utxo is fully authenticated.
+ bool is_bip322_to_spend = st->bip322.is_message_signing && cur_input_index == 0;
+ if (segwit_version == 0 && !input.has_nonWitnessUtxo && !is_bip322_to_spend) {
PRINTF("Non-witness utxo missing for segwitv0 input. Will show a warning.\n");
st->warnings.missing_nonwitnessutxo = true;
}
### src/handler/sign_psbt/transaction_display.c
@@ -16,6 +16,7 @@
*****************************************************************************/
#include <stdint.h>
+#include <stdio.h>
#include <string.h>
#include "transaction_display.h"
@@ -34,6 +35,7 @@
#include "psbt_fields.h"
#include "script.h"
#include "sighash.h"
+#include "stream_merkleized_map_value.h"
#include "sw.h"
#include "wallet.h"
@@ -436,3 +438,108 @@ bool __attribute__((noinline)) display_transaction(
return true;
}
+
+// State for the message-copying streaming pass (into the buffer shown in the review).
+typedef struct {
+ char *out;
+ size_t max; // capacity of out, excluding the terminating NUL
+ size_t offset;
+ bool overflow;
+} msg_copy_state_t;
+
+static void message_copy_callback(buffer_t *data, void *cb_state) {
+ msg_copy_state_t *state = (msg_copy_state_t *) cb_state;
+ size_t len = data->size - data->offset;
+
+ if (state->overflow || state->offset + len > state->max) {
+ state->overflow = true;
+ return;
+ }
+ memcpy(state->out + state->offset, data->ptr + data->offset, len);
+ state->offset += len;
+}
+
+bool __attribute__((noinline)) display_bip322_message(dispatcher_context_t *dc,
+ sign_psbt_state_t *st) {
+ LOG_PROCESSOR(__FILE__, __LINE__, __func__);
+
+ // Show any input verification warnings, exactly as the transaction review does. The
+ // external-inputs and non-default-sighash warnings are unreachable here, as
+ // validate_bip322_request() rejects both; the missing-non-witness-utxo warning can only concern
+ // the additional inputs of a proof-of-funds, as preprocess_inputs() exempts the to_spend input.
+ // This also keeps any warning added in the future from being silently skipped in this flow.
+ if (!display_warnings(dc, st)) {
+ return false;
+ }
+
+ char address[MAX_ADDRESS_LENGTH_STR + 1];
+ if (0 > get_script_address(st->bip322.challenge_script,
+ st->bip322.challenge_script_len,
+ address,
+ sizeof(address))) {
+ // wallet policies always produce scripts with an address; this should never happen
+ SEND_SW(dc, SW_BAD_STATE);
+ return false;
+ }
+
+ char account_label_buf[MAX_WALLET_NAME_LENGTH + 1];
+ const char *account_label = st->account.wallet_header.name;
+ if (st->account.is_default) {
+ account_label = NULL;
+ if (format_default_account_label(st->account.bip44_purpose,
+ st->account.bip44_account,
+ account_label_buf,
+ sizeof(account_label_buf))) {
+ account_label = account_label_buf;
+ }
+ }
+
+ char message[MAX_DISPLAYBLE_MESSAGE_LENGTH + 1];
+ bool is_hash = !st->bip322.message_printable;
+
+ if (!is_hash) {
+ // Stream the message again, this time into a buffer. The fetch is authenticated
+ // by the same Merkle commitment as the hashing pass in load_bip322_message(), so the client
+ // cannot provide different contents.
+ msg_copy_state_t copy_state = {.out = message,
+ .max = MAX_DISPLAYBLE_MESSAGE_LENGTH,
+ .offset = 0,
+ .overflow = false};
+ int message_length =
+ call_stream_merkleized_map_value(dc,
+ &st->global_map,
+ (uint8_t[]) {PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE},
+ 1,
+ NULL,
+ message_copy_callback,
+ ©_state);
+ if (message_length < 0 || copy_state.overflow ||
+ (uint64_t) message_length != st->bip322.message_length) {
+ SEND_SW(dc, SW_INCORRECT_DATA);
+ return false;
+ }
+ message[copy_state.offset] = '\0';
+ } else {
+ // The message is too long or not printable: show its sha256 hash instead.
+ for (int i = 0; i < 32; i++) {
+ snprintf(message + 2 * i, 3, "%02X", st->bip322.message_sha256[i]);
+ }
+ }
+
+ // For a proof-of-funds, also show the total amount of the coins whose control is proven.
+ bool is_proof_of_funds = st->n_inputs > 1;
+
+ ui_set_processing_screen_text(GA_SIGNING_MESSAGE);
+ if (!ui_display_bip322_message_and_confirm(dc,
+ account_label,
+ address,
+ message,
+ is_hash,
+ is_proof_of_funds,
+ st->inputs_total_amount)) {
+ SEND_SW(dc, SW_DENY);
+ return false;
+ }
+
+ return true;
+}
### src/handler/sign_psbt/transaction_display.h
@@ -44,3 +44,12 @@ bool display_transaction(
dispatcher_context_t *dc,
sign_psbt_state_t *st,
const uint8_t internal_outputs[static BITVECTOR_REAL_SIZE(MAX_N_OUTPUTS_CAN_SIGN)]);
+
+/**
+ * Shows the BIP-322 message review (account, address being proven, the total amount of the
+ * proven coins for a proof-of-funds, and the message text or its sha256 hash) and asks for
+ * user confirmation.
+ *
+ * Returns true if the user approved; returns false and sends an error status word otherwise.
+ */
+bool display_bip322_message(dispatcher_context_t *dc, sign_psbt_state_t *st);Why this scored 35/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.