For external input amounts, only show if trustworthy
What changed, and why it matters
This commit fixes a security issue in Ledger's Bitcoin app where the device could display a fake external input amount or fee to the user. When signing transactions with external inputs (coins controlled by someone else), the app previously trusted amounts supplied only via an unverified 'witness UTXO' for older SegWit v0 and legacy inputs. Because those signature types do not lock in the amounts of other inputs, a malicious computer could lie about how much those external inputs are worth, making the shown total, net amount, or fee look wrong while still producing a valid transaction. The fix hides the untrustworthy external-input total and fee in those cases, showing only the net amount the user's own account moves. For Taproot inputs, the amounts are cryptographically committed, so the full display remains.
Treat this commit as a security-hardening fix. Ensure it is included in the next release, run the new regression tests, and consider whether similar witness-utxo trust assumptions exist in other coin apps or signing paths.
Security signals we found
Fixes incorrect trust assumption in UI display for external input amounts
Prevents malicious host from forging displayed fee/total via unverified witness-utxo
Adds state flag tracking unverified external amounts
Adds regression tests covering SegWit v0 and Taproot behavior
Updates existing test documentation to match stricter trust model
Evidence from the diff
The patch addresses a UI trust issue in PSBT signing. For external inputs, if only a witness-utxo is provided and the internal inputs being signed are non-Taproot (legacy or SegWit v0), the input amount is not committed by the signature hash and is not hash-verified against the prevout txid. A malicious host can therefore fabricate the external input amount and the derived fee while the transaction remains valid (the external signer signs the real amount). The fix introduces an external_amount_unverified flag set during input preprocessing when an external input lacks a non-witness-utxo. During transaction display, it only shows the external-inputs total and fee when the input set is closed (no ANYONECANPAY) and either the signing policy is Taproot (BIP341 commits sha_amounts over all inputs) or every external amount was verified via non-witness-utxo. Otherwise it falls back to NET_ONLY, omitting the external-inputs total and showing the fee as unavailable. Tests are added for SegWit v0 (NET_ONLY) and Taproot (FULL) cases, and an existing SIGHASH_SINGLE test comment is updated to reflect the new behavior.
Changed components
src/handler/sign_psbt.hsrc/handler/sign_psbt/preprocess_inputs.csrc/handler/sign_psbt/transaction_display.ctests/test_sign_psbt.pytests/test_sign_psbt_with_sighash_types.pyInspect captured patch +103 / −10
diff --git a/src/handler/sign_psbt.h b/src/handler/sign_psbt.h
index f9a0947..6eacf5b 100644
--- a/src/handler/sign_psbt.h
+++ b/src/handler/sign_psbt.h
@@ -169,6 +169,12 @@ typedef struct {
bool sighash_inputs_open;
bool sighash_outputs_open;
+ // Set if some external input's amount is not verified by recomputing the txid (only a
+ // witness-utxo is provided, never validated against the prevout txid).
+ // Unless the internal inputs we sign are taproot inputs (which commit sha_amounts which
+ // includes all amounts), external amounts can't be trusted, nor can the fee.
+ bool external_amount_unverified;
+
// Common sighash seen across the inputs we sign (DEFAULT canonicalized to ALL); valid if
// !sighash_mixed.
uint32_t seen_sighash;
diff --git a/src/handler/sign_psbt/preprocess_inputs.c b/src/handler/sign_psbt/preprocess_inputs.c
index 235aea9..a533086 100644
--- a/src/handler/sign_psbt/preprocess_inputs.c
+++ b/src/handler/sign_psbt/preprocess_inputs.c
@@ -249,6 +249,12 @@ bool __attribute__((noinline)) preprocess_inputs(
} else if (is_internal == 0) {
++st->n_external_inputs;
st->warnings.external_inputs = true;
+ if (!input.has_nonWitnessUtxo) {
+ // The amount comes only from the (unverified) witness-utxo, not hash-verified
+ // against the prevout txid. Trustworthy only if we sign taproot inputs (which
+ // commit sha_amounts over all inputs).
+ st->external_amount_unverified = true;
+ }
PRINTF("INPUT %d is external\n", cur_input_index);
continue;
}
diff --git a/src/handler/sign_psbt/transaction_display.c b/src/handler/sign_psbt/transaction_display.c
index 5175d5a..21cbfb7 100644
--- a/src/handler/sign_psbt/transaction_display.c
+++ b/src/handler/sign_psbt/transaction_display.c
@@ -35,6 +35,7 @@
#include "script.h"
#include "sighash.h"
#include "sw.h"
+#include "wallet.h"
static bool __attribute__((noinline)) display_output(
dispatcher_context_t *dc,
@@ -268,11 +269,20 @@ bool __attribute__((noinline)) display_transaction(
bool has_external_inputs = st->warnings.external_inputs;
uint64_t external_inputs_amount = st->inputs_total_amount - st->internal_inputs_total_amount;
- // The external inputs total is only meaningful once their set is fixed: if any signed input is
- // ANYONECANPAY the set is open and more inputs could be appended after signing. When closed,
- // the amount is trustworthy regardless of the output/fee display mode, so we show it in
- // NET_ONLY too.
- bool show_external_inputs_amount = has_external_inputs && !st->sighash_inputs_open;
+ // The external inputs total (and the fee, consequently) can only be trusted when those amounts
+ // are committed to in the signatures: the input set must be closed (no ANYONECANPAY, else
+ // more inputs can be appended after signing) and either we sign taproot (BIP341 commits
+ // sha_amounts over every input) or each external amount was hash-verified via its
+ // non-witness-utxo.
+ // Otherwise a malicious host could forge them via an unverified witness-utxo while the
+ // transaction stays valid (the external party signs with the real one).
+ bool signing_taproot = (get_policy_segwit_version(st->account.policy_map) == 1);
+ bool external_amounts_committed =
+ !st->sighash_inputs_open && (signing_taproot || !st->external_amount_unverified);
+
+ // When pinned, the external inputs total is correct regardless of the output/fee display mode,
+ // so we show it in NET_ONLY too.
+ bool show_external_inputs_amount = has_external_inputs && external_amounts_committed;
// Default sighash => FULL. Non-default => show only what the signed inputs commit to.
tx_display_mode_t mode = TX_DISPLAY_FULL;
@@ -281,11 +291,16 @@ bool __attribute__((noinline)) display_transaction(
mode = TX_DISPLAY_UNAVAILABLE; // signed inputs disagree: nothing coherent
} else {
// uniform non-default sighash never fixes the whole set => fee never trustworthy
- bool fee_trustworthy = false;
bool outputs_committed =
sighash_commits_provided_outputs(st->seen_sighash, st->n_outputs);
- mode = decide_tx_display_mode(fee_trustworthy, outputs_committed);
+ mode = decide_tx_display_mode(/*fee_trustworthy=*/false, outputs_committed);
}
+ } else if (has_external_inputs && !external_amounts_committed) {
+ // Default sighash closes both the input and output sets, but the fee sums external
+ // amounts that are not committed to in the signature, so it isn't trustworthy. The outputs
+ // and the net amount from/to the spending account still are, so fall back to NET_ONLY (fee
+ // shown as "Not available").
+ mode = decide_tx_display_mode(/*fee_trustworthy=*/false, /*commits_all_outputs=*/true);
}
tx_summary_t summary = {.mode = mode,
diff --git a/tests/test_sign_psbt.py b/tests/test_sign_psbt.py
index 7a8af0a..7950a38 100644
--- a/tests/test_sign_psbt.py
+++ b/tests/test_sign_psbt.py
@@ -919,6 +919,70 @@ def test_sign_psbt_with_external_inputs_net_receive(navigator: Navigator, firmwa
assert len(hww_sigs) == 1
+def test_sign_psbt_external_input_unverified_segwitv0_net_only(navigator: Navigator, firmware: Firmware, client:
+ RaggerClient, test_name: str):
+ # A wpkh (segwit v0) spend with an external input that provides only a (foreign) witness UTXO:
+ # its amount is never hash-verified against the prevout txid, and segwit v0 signatures don't
+ # commit to external inputs' amounts. A malicious host could therefore forge the external
+ # amount and the fee while the transaction stays valid (the external party signs its input with
+ # the real amount), so neither is trustworthy. The review must fall back to NET_ONLY: the
+ # trustworthy net "You spend" is shown, but the external-inputs total is omitted and the fee is
+ # shown as "Not available".
+ wallet = WalletPolicy(
+ "",
+ "wpkh(@0/**)",
+ [
+ "[f5acc2fd/84'/1'/0']tpubDCtKfsNyRhULjZ9XMS4VKKtVcPdVDi8MKUbcSD9MJDyjRu1A2ND5MiipozyyspBT9bg8upEp7a8EAgFxNxXn1d7QkdbL52Ty5jiSLcxPt1P"
+ ],
+ )
+
+ # Input 1 is external (foreign witness-utxo only, no non-witness-utxo).
+ psbt = txmaker.createPsbt(
+ wallet,
+ [3 * 100_000_000, 1 * 100_000_000],
+ [350_000_000, 49_000_000],
+ [False, True],
+ input_is_external=[False, True],
+ )
+
+ hww_sigs = client.sign_psbt(psbt, wallet, None, navigator,
+ instructions=sign_psbt_instruction_approve(firmware, has_external_inputs=True),
+ testname=test_name)
+
+ assert len(hww_sigs) == 1
+
+
+def test_sign_psbt_external_input_unverified_taproot_full(navigator: Navigator, firmware: Firmware, client:
+ RaggerClient, test_name: str):
+ # Same shape as the segwit v0 test above, but signing a tr (taproot) account. A BIP341
+ # signature commits sha_amounts over *every* input, so even an external input with only an
+ # (unverified) witness UTXO is pinned to the transaction: lying about its amount would
+ # invalidate our signature. The review therefore stays FULL, showing the external-inputs total
+ # and the fee.
+ wallet = WalletPolicy(
+ "",
+ "tr(@0/**)",
+ [
+ "[f5acc2fd/86'/1'/0']tpubDDKYE6BREvDsSWMazgHoyQWiJwYaDDYPbCFjYxN3HFXJP5fokeiK4hwK5tTLBNEDBwrDXn8cQ4v9b2xdW62Xr5yxoQdMu1v6c7UDXYVH27U"
+ ],
+ )
+
+ # Input 1 is external (foreign witness-utxo only, no non-witness-utxo).
+ psbt = txmaker.createPsbt(
+ wallet,
+ [3 * 100_000_000, 1 * 100_000_000],
+ [350_000_000, 49_000_000],
+ [False, True],
+ input_is_external=[False, True],
+ )
+
+ hww_sigs = client.sign_psbt(psbt, wallet, None, navigator,
+ instructions=sign_psbt_instruction_approve(firmware, has_external_inputs=True),
+ testname=test_name)
+
+ assert len(hww_sigs) == 1
+
+
def test_sign_psbt_miniscript_multikey(navigator: Navigator, firmware: Firmware, client:
RaggerClient, test_name: str):
# An earlier (unreleased) version of the app had issues in recognizing the internal key in
diff --git a/tests/test_sign_psbt_with_sighash_types.py b/tests/test_sign_psbt_with_sighash_types.py
index a247c9e..74cc114 100644
--- a/tests/test_sign_psbt_with_sighash_types.py
+++ b/tests/test_sign_psbt_with_sighash_types.py
@@ -769,9 +769,11 @@ def test_sighash_two_outputs_anyonecanpay_net_only(navigator: Navigator, firmwar
def test_sighash_single_external_inputs_net_only(navigator: Navigator, firmware: Firmware, client: RaggerClient, test_name: str):
- # NET_ONLY (SIGHASH_SINGLE, one committed output) with an external input and a closed input set:
- # the "External inputs amount" row is trustworthy here too, so it is shown alongside the net
- # "You spend" and the untrusted "Fees: Not available".
+ # NET_ONLY (SIGHASH_SINGLE, one committed output) with an external input and a closed input set.
+ # The input set being closed is not enough to trust the external amount: this is a segwit v0
+ # (wpkh) signing, whose signatures don't commit to external inputs' amounts, and the external
+ # input provides only an (unverified) witness UTXO. So the "External inputs amount" row must be
+ # omitted; only the net "You spend"/"You receive" and "Fees: Not available" are shown.
toggle_nonstandard_sighash_setting(navigator, firmware)
# Two-input P2WPKH PSBT: input 0 is ours with SIGHASH_SINGLE, input 1 is an external
# 500,000-sat input, and the single output includes those external funds minus a 145-sat fee.
Why this scored 66/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.