feat: warn when PSBT input amounts cannot be verified
What changed, and why it matters
This commit adds a warning screen to Krux, a hardware signing device, when it is asked to sign a multi-input Bitcoin transaction where the amounts of some inputs cannot be independently verified. The risk is that a malicious transaction coordinator could trick a user into paying a much higher fee than shown, or into signing away more money than intended, by lying about input amounts during separate signing sessions. The patch does not block signing; it only warns the user and asks whether to proceed. It also does not fix the underlying cryptographic gap in older SegWit (BIP143) signatures, which is why the warning is needed.
Users should upgrade to a release containing this commit and carefully read the new warning. Developers should consider whether the residual risk of allowing user override is acceptable, or whether an optional strict mode that rejects unverified multi-input SegWit PSBTs should be added. Wallet coordinators used with Krux should be configured to include non-witness UTXO data for SegWit inputs when possible.
Security signals we found
New user-facing warning for unverified multi-input SegWit amounts
Detection logic tied to BIP143 signature semantics and inp.is_verified
Does not enforce previous-transaction inclusion; user can still proceed
Residual exposure explicitly documented in skipped test comment
No CVE, vendor advisory, or independent researcher attribution in commit
Evidence from the diff
The change introduces PSBTSigner.unverified_input_amounts() in src/krux/psbt.py. It returns true when the PSBT has two or more inputs, the wallet policy is not P2TR/taproot, and any input lacks a verified previous transaction (inp.is_verified is false). The UI in src/krux/pages/home_pages/home.py then displays a warning before the existing fee warning and aborts signing if the user declines. The commit message and tests explain the attack: for non-taproot SegWit (BIP143), each signature commits only to its own input amount, so a coordinator can present different PSBTs across sessions, each truthfully declaring one input amount, and combine the resulting signatures into a valid transaction with a hidden fee or altered change. Taproot (BIP341) and single-input transactions are exempt because they commit to all input amounts or the lie invalidates its own signature, respectively. Tests cover the warning path, the no-warning paths, and update existing sign-PSBT button sequences.
Changed components
src/krux/psbt.pysrc/krux/pages/home_pages/home.pyPSBT signing flow for multi-input non-taproot SegWit transactionsInspect captured patch +159 / −2
diff --git a/src/krux/pages/home_pages/home.py b/src/krux/pages/home_pages/home.py
index 02a48ef..5c6b0bd 100644
--- a/src/krux/pages/home_pages/home.py
+++ b/src/krux/pages/home_pages/home.py
@@ -411,6 +411,23 @@ class Home(Page):
return True
+ def _unverified_amounts_psbt_warn(self, signer):
+ """Warn when input amounts are not backed by their previous transactions"""
+ if signer.unverified_input_amounts():
+ self.ctx.display.clear()
+ self.ctx.display.draw_centered_text(
+ t("Warning:")
+ + " "
+ + t("Unverified input amounts!")
+ + "\n"
+ + t("The fee shown may be lower than the real fee."),
+ highlight_prefix=":",
+ )
+
+ return self.prompt(t("Proceed?"), BOTTOM_PROMPT_LINE)
+
+ return True
+
def _fees_psbt_warn(self, fee_percent):
"""Warn if fees greater than 10% of what is spent"""
if fee_percent >= 10.0:
@@ -510,6 +527,9 @@ class Home(Page):
self.ctx.display.draw_centered_text(t("Processing…"))
outputs, fee_percent = signer.outputs()
+ if not self._unverified_amounts_psbt_warn(signer):
+ return MENU_CONTINUE
+
if not self._fees_psbt_warn(fee_percent):
return MENU_CONTINUE
diff --git a/src/krux/psbt.py b/src/krux/psbt.py
index bdf892e..14e701d 100644
--- a/src/krux/psbt.py
+++ b/src/krux/psbt.py
@@ -200,6 +200,19 @@ class PSBTSigner:
if self.wallet.policy != self.policy:
raise ValueError("policy mismatch")
+ def unverified_input_amounts(self):
+ """True if an input amount could be understated without breaking its signature.
+
+ BIP143 commits only to the amount of the input being signed, so with more
+ than one input a coordinator can declare a different amount truthfully in
+ each of two signing sessions and combine one valid signature per input.
+ BIP341 hashes every input amount, so taproot is immune, and with a single
+ input the lie goes into its own sighash and invalidates it.
+ """
+ if len(self.psbt.inputs) < 2 or self.policy["type"] == P2TR:
+ return False
+ return any(not inp.is_verified for inp in self.psbt.inputs)
+
def get_policy_from_psbt_input(self, tx_input, xpubs, origin_less_xpub=None):
"""Extracts the scriptPubKey from an input's UTXO and determines the policy."""
# Same UTXO object the signer commits to, so policy, displayed amount
diff --git a/tests/pages/home_pages/test_home.py b/tests/pages/home_pages/test_home.py
index 2ee00ea..a5560a8 100644
--- a/tests/pages/home_pages/test_home.py
+++ b/tests/pages/home_pages/test_home.py
@@ -941,6 +941,7 @@ def test_sign_psbt(mocker, m5stickv, tdata):
BUTTON_ENTER, # Load from QR code
BUTTON_ENTER, # Path mismatch ACK
BUTTON_ENTER, # PSBT Policy ACK
+ BUTTON_ENTER, # Unverified input amounts ACK
BUTTON_ENTER, # PSBT resume
BUTTON_ENTER, # output 1
BUTTON_ENTER, # output 2
@@ -966,6 +967,7 @@ def test_sign_psbt(mocker, m5stickv, tdata):
BUTTON_ENTER, # Load from QR code
BUTTON_ENTER, # Path mismatch ACK
BUTTON_ENTER, # PSBT Policy ACK
+ BUTTON_ENTER, # Unverified input amounts ACK
BUTTON_ENTER, # PSBT resume
BUTTON_ENTER, # output 1
BUTTON_ENTER, # output 2
@@ -992,6 +994,7 @@ def test_sign_psbt(mocker, m5stickv, tdata):
BUTTON_ENTER, # Load from QR code
BUTTON_ENTER, # Path mismatch ACK
BUTTON_ENTER, # PSBT Policy ACK
+ BUTTON_ENTER, # Unverified input amounts ACK
BUTTON_ENTER, # PSBT resume
BUTTON_ENTER, # output 1
BUTTON_ENTER, # output 2
@@ -1042,6 +1045,7 @@ def test_sign_psbt(mocker, m5stickv, tdata):
BUTTON_ENTER, # Load from SD card
BUTTON_ENTER, # Path mismatch ACK
BUTTON_ENTER, # PSBT Policy ACK
+ BUTTON_ENTER, # Unverified input amounts ACK
BUTTON_ENTER, # PSBT resume
BUTTON_ENTER, # output 1
BUTTON_ENTER, # output 2
@@ -1230,6 +1234,7 @@ def test_psbt_warnings(mocker, m5stickv, tdata):
BUTTON_ENTER, # Load from SD card
BUTTON_ENTER, # Path mismatch ACK
BUTTON_ENTER, # PSBT Policy ACK
+ BUTTON_ENTER, # Unverified input amounts ACK
BUTTON_ENTER, # PSBT resume
BUTTON_ENTER, # output 1
BUTTON_ENTER, # output 2
@@ -1912,3 +1917,37 @@ def test_sign_spent_and_self(mocker, m5stickv, tdata):
),
]
)
+
+
+def test_unverified_amounts_warning(mocker, m5stickv):
+ """The warning must be shown and must abort signing when declined"""
+ from krux.pages.home_pages.home import Home
+ from krux.input import BUTTON_ENTER, BUTTON_PAGE
+
+ class FakeSigner:
+ def __init__(self, unverified):
+ self.unverified = unverified
+
+ def unverified_input_amounts(self):
+ return self.unverified
+
+ # Declined
+ ctx = create_ctx(mocker, [BUTTON_PAGE])
+ home = Home(ctx)
+ mocker.spy(ctx.display, "draw_centered_text")
+ assert home._unverified_amounts_psbt_warn(FakeSigner(True)) is False
+ shown = ctx.display.draw_centered_text.call_args[0][0]
+ assert "Unverified input amounts!" in shown
+ assert "The fee shown may be lower than the real fee." in shown
+
+ # Accepted
+ ctx = create_ctx(mocker, [BUTTON_ENTER])
+ home = Home(ctx)
+ assert home._unverified_amounts_psbt_warn(FakeSigner(True)) is True
+
+ # Nothing to warn about, no prompt consumed
+ ctx = create_ctx(mocker, [])
+ home = Home(ctx)
+ mocker.spy(ctx.display, "draw_centered_text")
+ assert home._unverified_amounts_psbt_warn(FakeSigner(False)) is True
+ assert ctx.display.draw_centered_text.call_count == 0
diff --git a/tests/test_psbt_input_amounts.py b/tests/test_psbt_input_amounts.py
index 28cf828..6c7bc50 100644
--- a/tests/test_psbt_input_amounts.py
+++ b/tests/test_psbt_input_amounts.py
@@ -229,6 +229,89 @@ def _compressed_psbt_with_contradicting_amounts(root, real_value, declared_value
return raw, tx, pubkey, script_pubkey
+def _two_input_psbt(root, taproot=False, with_prev_txs=False):
+ """Two input PSBT, optionally taproot, optionally carrying previous txs"""
+ from embit import script
+ from embit.psbt import PSBT
+ from embit.transaction import Transaction, TransactionInput, TransactionOutput
+
+ base = "m/86h/1h/0h" if taproot else "m/84h/1h/0h"
+ make = script.p2tr if taproot else script.p2wpkh
+
+ keys, prevs = [], []
+ for i in (0, 1):
+ pubkey, derivation = _key_at(root, "%s/0/%d" % (base, i))
+ keys.append((pubkey, derivation))
+ prevs.append(
+ Transaction(
+ vin=[TransactionInput(bytes([0xA0 + i]) * 32, 0)],
+ vout=[TransactionOutput(100000000, make(pubkey))],
+ )
+ )
+
+ tx = Transaction(
+ vin=[TransactionInput(prev.txid(), 0) for prev in prevs],
+ vout=[TransactionOutput(199990000, make(_key_at(root, "%s/0/7" % base)[0]))],
+ )
+ psbt = PSBT(tx)
+ for i, (pubkey, derivation) in enumerate(keys):
+ psbt.inputs[i].witness_utxo = prevs[i].vout[0]
+ if with_prev_txs:
+ psbt.inputs[i].non_witness_utxo = prevs[i]
+ if taproot:
+ psbt.inputs[i].taproot_bip32_derivations[pubkey] = ([], derivation)
+ psbt.inputs[i].taproot_internal_key = pubkey
+ else:
+ psbt.inputs[i].bip32_derivations[pubkey] = derivation
+ return psbt.serialize()
+
+
+def _taproot_wallet():
+ from embit.networks import NETWORKS
+ from krux.key import Key, TYPE_SINGLESIG, P2TR
+ from krux.wallet import Wallet
+
+ return Wallet(Key(TEST_MNEMONIC, TYPE_SINGLESIG, NETWORKS["test"], "", 0, P2TR))
+
+
+def test_warns_when_amounts_are_unverifiable(m5stickv):
+ """Two segwit v0 inputs with no previous transactions is the Path C setup"""
+ from krux.psbt import PSBTSigner
+ from krux.qr import FORMAT_NONE
+
+ signer = PSBTSigner(_wallet(), _two_input_psbt(_root()), FORMAT_NONE)
+ assert signer.unverified_input_amounts() is True
+
+
+def test_no_warning_with_previous_transactions(m5stickv):
+ """Verified amounts cannot be understated, so there is nothing to warn about"""
+ from krux.psbt import PSBTSigner
+ from krux.qr import FORMAT_NONE
+
+ raw = _two_input_psbt(_root(), with_prev_txs=True)
+ signer = PSBTSigner(_wallet(), raw, FORMAT_NONE)
+ assert signer.unverified_input_amounts() is False
+
+
+def test_no_warning_for_taproot(m5stickv):
+ """BIP341 hashes every input amount, so the two session trick cannot work"""
+ from krux.psbt import PSBTSigner
+ from krux.qr import FORMAT_NONE
+
+ raw = _two_input_psbt(_root(), taproot=True)
+ signer = PSBTSigner(_taproot_wallet(), raw, FORMAT_NONE)
+ assert signer.unverified_input_amounts() is False
+
+
+def test_no_warning_for_single_input(m5stickv):
+ """A lie about the only input goes into its own sighash and breaks it"""
+ from krux.psbt import PSBTSigner
+ from krux.qr import FORMAT_NONE
+
+ signer = PSBTSigner(_wallet(), _segwit_psbt(_root(), 100000, 90000), FORMAT_NONE)
+ assert signer.unverified_input_amounts() is False
+
+
def test_displayed_amount_is_the_signed_amount(mocker, m5stickv):
"""Display and sighash must read the same UTXO, even in compressed mode"""
from embit import ec, script
@@ -312,8 +395,10 @@ def test_compressed_parse_keeps_legacy_psbt_usable(mocker, m5stickv):
reason="Segwit inputs are not required to carry a previous transaction, so "
"their amounts stay unverified. Signing the same transaction twice, each "
"session declaring a different input truthfully, yields one valid signature "
- "per input. Closing this means requiring previous transactions on segwit "
- "inputs too, which rejects PSBTs from coordinators that omit them.",
+ "per input. Krux warns about this through unverified_input_amounts() but "
+ "still signs if the user proceeds. Rejecting instead would need previous "
+ "transactions on segwit inputs, which Sparrow deliberately omits for Krux "
+ "and the other airgapped signers in WalletModel.alwaysIncludeNonWitnessUtxo.",
)
def test_segwit_input_amounts_are_verified(m5stickv):
"""Documents the residual exposure on multi input segwit transactions"""
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.