Verify owned PSBT inputs before fee review
What changed, and why it matters
This firmware update fixes a bug in the Passport hardware wallet's handling of Bitcoin transaction files (PSBTs). Previously, the wallet could be tricked into reviewing and displaying a transaction fee as verified even when an input the wallet appeared to own was actually controlled by a different key. The fix makes the device cryptographically prove it really owns each input before it tells the user the fee is trustworthy, helping prevent attackers from showing misleading fee amounts.
Treat this as a security-relevant firmware fix. Users should upgrade to a firmware release containing this commit. Developers should review whether any other fee-trust or change-output decisions rely on metadata checks without cryptographic derivation, and extend similar verification there.
Security signals we found
Missing cryptographic ownership check before trust decision
PSBT input public key not verified against derived key before fee review
fee_is_verified could be set true for inputs Passport does not actually control
Refactoring duplicates ownership-verification logic into a single method
Unit test added for forged public key case
Evidence from the diff
The commit moves public-key derivation and ownership verification from the signing task into a new psbtInputProxy.get_signing_node() method, and calls it earlier during consider_inputs(). Before this change, consider_inputs() only checked whether an input had BIP32 derivation metadata matching the device’s fingerprint (num_our_keys and required_key) to decide fee_is_verified. It did not actually derive the key and compare the resulting public key. A malicious PSBT could therefore supply a forged public key with a derivation path that appears owned, causing fee_is_verified to remain true even though Passport cannot sign that input. The patch derives the node and asserts pubkey equality before fee review, so fee verification now fails if ownership cannot be proven. The signing task reuses the same helper, removing duplicated logic.
Changed components
ports/stm32/boards/Passport/modules/psbt.pyports/stm32/boards/Passport/modules/tasks/sign_psbt_task.pyports/stm32/boards/Passport/modules/tests/unit/psbt_fee.pyInspect captured patch +78 / −54
### ports/stm32/boards/Passport/modules/psbt.py
@@ -857,6 +857,40 @@ def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt):
# Could probably free self.subpaths and self.redeem_script now, but only if we didn't
# need to re-serialize as a PSBT.
+ def get_signing_node(self, sv, my_xfp, my_idx):
+ if self.is_multisig:
+ # The fingerprint is only a hint. Derive each candidate to prove
+ # that Passport owns one of the public keys required by the script.
+ for which_key in self.required_key:
+ skp = keypath_to_str(self.subpaths[which_key])
+ node = sv.derive_path(skp, register=False)
+ if node.public_key() == which_key:
+ return node, which_key
+
+ raise AssertionError("Input #%d needs pubkey this Passport doesn't have." % my_idx)
+
+ which_key = self.required_key
+
+ if self.subpaths and \
+ (self.subpaths[which_key][0] == my_xfp or
+ self.subpaths[which_key][0] == swab32(my_xfp)):
+ skp = keypath_to_str(self.subpaths[which_key])
+ node = sv.derive_path(skp, register=False)
+ pubkey = node.public_key()
+ elif self.tap_subpaths and \
+ (self.tap_subpaths[which_key][0][0] == my_xfp or
+ self.tap_subpaths[which_key][0][0] == swab32(my_xfp)):
+ skp = keypath_to_str(self.tap_subpaths[which_key][0])
+ node = sv.derive_path(skp, register=False)
+ pubkey = node.public_key()[1:]
+ else:
+ raise AssertionError("Input #%d has no Passport signing path." % my_idx)
+
+ if pubkey != which_key:
+ raise AssertionError("Path (%s) led to wrong pubkey for input #%d" % (skp, my_idx))
+
+ return node, which_key
+
def store(self, kt, key, val):
# Capture what we are interested in.
@@ -1439,11 +1473,13 @@ def consider_inputs(self):
# - also finds appropriate multisig wallet to be used
inp.determine_my_signing_key(i, utxo, self.my_xfp, self)
- # A false amount for an input we sign makes our signature invalid,
- # and the history cache catches changed amounts across attempts.
- if inp.witness_utxo and not inp.utxo and \
- not (inp.num_our_keys and inp.required_key):
- self.fee_is_verified = False
+ if inp.witness_utxo and not inp.utxo:
+ if inp.num_our_keys and inp.required_key:
+ import stash
+ with stash.SensitiveValues() as sv:
+ inp.get_signing_node(sv, self.my_xfp, i)
+ else:
+ self.fee_is_verified = False
gc.collect()
### ports/stm32/boards/Passport/modules/tasks/sign_psbt_task.py
@@ -13,7 +13,6 @@
async def sign_psbt_task(on_done, psbt):
from exceptions import FraudulentChangeOutput, FatalPSBTIssue
from errors import Error
- from utils import keypath_to_str, swab32
from serializations import ser_sig_der
import stash
import gc
@@ -62,51 +61,11 @@ async def sign_psbt_task(on_done, psbt):
digest = psbt.make_txn_segwit_sighash(in_idx, txi,
inp.amount, inp.scriptCode, inp.sighash)
- if inp.is_multisig:
- # need to consider a set of possible keys, since xfp may not be unique
- for which_key in inp.required_key:
- # get node required
- skp = keypath_to_str(inp.subpaths[which_key])
- node = sv.derive_path(skp, register=False)
+ node, which_key = inp.get_signing_node(sv, psbt.my_xfp, in_idx)
- # expensive test, but works... and important
- pu = node.public_key()
- if pu == which_key:
- break
- else:
- raise AssertionError("Input #%d needs pubkey this Passport doesn't have." % in_idx)
-
- else:
- # single pubkey <=> single key
- which_key = inp.required_key
-
- assert not (inp.added_sig or inp.tap_key_sig), "This transaction has already been signed"
-
- if len(inp.subpaths) > 0 and \
- (inp.subpaths[which_key][0] == psbt.my_xfp or
- inp.subpaths[which_key][0] == swab32(psbt.my_xfp)):
-
- # get node required
- skp = keypath_to_str(inp.subpaths[which_key])
- node = sv.derive_path(skp, register=False)
-
- # expensive test, but works... and important
- pu = node.public_key()
-
- # tap_subpaths have type ([path_elements], [tap_hashes])
- elif len(inp.tap_subpaths) > 0 and \
- (inp.tap_subpaths[which_key][0][0] == psbt.my_xfp or
- inp.tap_subpaths[which_key][0][0] == swab32(psbt.my_xfp)):
-
- # get node required
- skp = keypath_to_str(inp.tap_subpaths[which_key][0])
- node = sv.derive_path(skp, register=False)
-
- # expensive test, but works... and important
- pu = node.public_key()[1:]
-
- if pu != which_key:
- raise AssertionError("Path (%s) led to wrong pubkey for input #%d" % (skp, in_idx))
+ if not inp.is_multisig:
+ assert not (inp.added_sig or inp.tap_key_sig), \
+ "This transaction has already been signed"
# The precious private key we need
pk = node.private_key()
### ports/stm32/boards/Passport/modules/tests/unit/psbt_fee.py
@@ -7,6 +7,7 @@
from ustruct import pack
import history
+import stash
from flows.sign_psbt_common_flow import SignPsbtCommonFlow
from psbt import psbtInputProxy, psbtObject
from public_constants import (
@@ -20,7 +21,7 @@
P2WPKH_SCRIPT = b'\x00\x14' + (b'\x11' * 20)
MY_XFP = 0x12345678
OWNED_PUBKEY = b'\x02' + (b'\x55' * 32)
-OWNED_SCRIPT = b'\x00\x14' + hash160(OWNED_PUBKEY)
+FORGED_PUBKEY = b'\x03' + (b'\x66' * 32)
def psbt_field(key_type, value, key=b''):
@@ -41,10 +42,10 @@ def make_input(witness_txout, non_witness_txout=None):
return psbtInputProxy(BytesIO(data + b'\x00'), 0)
-def make_owned_input():
- txout = CTxOut(2000, OWNED_SCRIPT)
+def make_owned_input(pubkey=OWNED_PUBKEY):
+ txout = CTxOut(2000, b'\x00\x14' + hash160(pubkey))
data = psbt_field(PSBT_IN_WITNESS_UTXO, txout.serialize())
- data += psbt_field(PSBT_IN_BIP32_DERIVATION, pack('<II', MY_XFP, 0), OWNED_PUBKEY)
+ data += psbt_field(PSBT_IN_BIP32_DERIVATION, pack('<II', MY_XFP, 0), pubkey)
return psbtInputProxy(BytesIO(data + b'\x00'), 0)
@@ -78,6 +79,26 @@ class FakeTxIn:
prevout = FakePrevout()
+class FakeNode:
+ @staticmethod
+ def public_key():
+ return OWNED_PUBKEY
+
+
+class FakeSensitiveValues:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, _exc_type, _exc, _traceback):
+ pass
+
+ @staticmethod
+ def derive_path(path, register=False):
+ assert path == 'm/0'
+ assert not register
+ return FakeNode()
+
+
class FakeInputPSBT:
def __init__(self, psbt_input, my_xfp=0):
self.inputs = [psbt_input]
@@ -94,7 +115,9 @@ def input_iter(self):
verified_amounts = []
original_verify_amount = history.verify_amount
+original_sensitive_values = stash.SensitiveValues
history.verify_amount = lambda _prevout, amount, idx: verified_amounts.append((amount, idx))
+stash.SensitiveValues = FakeSensitiveValues
try:
external_input_psbt = FakeInputPSBT(make_input(CTxOut(2000, P2WPKH_SCRIPT)))
psbtObject.consider_inputs(external_input_psbt)
@@ -107,8 +130,14 @@ def input_iter(self):
assert owned_input_psbt.fee_is_verified
assert owned_input.num_our_keys == 1
assert owned_input.required_key == OWNED_PUBKEY
+
+ forged_input = make_owned_input(FORGED_PUBKEY)
+ forged_input.validate(0, FakeTxIn(), MY_XFP)
+ forged_input_psbt = FakeInputPSBT(forged_input, MY_XFP)
+ assert_raises(AssertionError, lambda: psbtObject.consider_inputs(forged_input_psbt))
finally:
history.verify_amount = original_verify_amount
+ stash.SensitiveValues = original_sensitive_values
assert verified_amounts == [(2000, 0), (2000, 0)]
Why this scored 73/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.