Preserve SegWit input classification
What changed, and why it matters
This firmware update changes how Passport classifies SegWit Bitcoin transaction inputs. Previously, the device tried to detect SegWit by inspecting the address type and redeem script, and would reject a PSBT that supplied a witness UTXO for what it thought was a non-SegWit input. Now, if a PSBT includes a witness UTXO, the input is marked SegWit immediately. The patch removes several safety checks, which could allow a malicious or malformed PSBT to bypass fee verification or signature checks. The commit message does not describe a security fix, but the change is in security-relevant code.
Treat as a security-relevant change requiring review. Verify that removing script-based SegWit classification cannot be exploited via a malicious PSBT to bypass fee verification, trick the user into signing a high-fee transaction, or cause incorrect signature hashing. Re-add tests for non-SegWit and wrapped-SegWit edge cases if they remain supported. Request a security advisory or changelog entry from the vendor.
Security signals we found
Removal of FatalPSBTIssue raise for witness UTXO on non-SegWit input
Removal of redeem-script-based SegWit classification
Removal of unit tests covering legacy and wrapped SegWit detection
Addition of unconditional is_segwit = True when witness_utxo is present
Fee verification logic depends on is_segwit and witness_utxo flags
No explicit security rationale or CVE in commit message
Evidence from the diff
In psbt.py, get_utxo() now sets self.is_segwit = True whenever a witness_utxo is present. determine_my_signing_key() no longer derives is_segwit from the UTXO’s scriptPubKey/redeem_script and no longer raises FatalPSBTIssue for witness UTXOs on non-SegWit inputs. The unit test removes the legacy non-SegWit rejection test, the native/wrapped SegWit classification tests, and the redeem-script path. It adds a check that matching_input.is_segwit is True and that external witness-UTXO inputs cause fee_is_verified = False. The change weakens the link between script type and SegWit flag, relying instead on the PSBT’s witness_utxo field.
Changed components
Passport firmware PSBT parsing module (ports/stm32/boards/Passport/modules/psbt.py)Passport firmware PSBT unit tests (ports/stm32/boards/Passport/modules/tests/unit/psbt_fee.py)Inspect captured patch +34 / −53
### ports/stm32/boards/Passport/modules/psbt.py
@@ -647,6 +647,7 @@ def get_utxo(self, idx):
if self.witness_utxo:
# Load the compact output. If the full previous transaction is also
# present, its hash-bound output is loaded below and must match.
+ self.is_segwit = True
fd.seek(self.witness_utxo[0])
witness_utxo = CTxOut()
@@ -712,18 +713,8 @@ def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt):
which_key = None
addr_type, addr_or_pubkey, addr_is_segwit = utxo.get_address()
- self.is_segwit = addr_is_segwit
-
- if self.witness_utxo and not self.is_segwit:
- if addr_type == 'p2sh' and self.redeem_script:
- redeem_script = self.get(self.redeem_script)
- assert hash160(redeem_script) == addr_or_pubkey, \
- "redeem script mismatch for input #%d" % my_idx
- self.is_segwit = len(redeem_script) in {22, 34} and \
- redeem_script[0] == 0 and redeem_script[1] in {20, 32}
-
- if not self.is_segwit:
- raise FatalPSBTIssue("Witness UTXO provided for non-SegWit input #%d" % my_idx)
+ if addr_is_segwit and not self.is_segwit:
+ self.is_segwit = True
if addr_type == 'p2sh':
# multisig input
@@ -1448,6 +1439,8 @@ 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
### ports/stm32/boards/Passport/modules/tests/unit/psbt_fee.py
@@ -6,20 +6,17 @@
from uio import BytesIO
from ustruct import pack
-from exceptions import FatalPSBTIssue
+import history
from flows.sign_psbt_common_flow import SignPsbtCommonFlow
from psbt import psbtInputProxy, psbtObject
from public_constants import (
PSBT_IN_NON_WITNESS_UTXO,
- PSBT_IN_REDEEM_SCRIPT,
PSBT_IN_WITNESS_UTXO,
)
-from serializations import CTxOut, hash160, ser_compact_size
+from serializations import CTxOut, ser_compact_size
P2WPKH_SCRIPT = b'\x00\x14' + (b'\x11' * 20)
-P2PKH_SCRIPT = b'\x76\xa9\x14' + (b'\x22' * 20) + b'\x88\xac'
-DUMMY_PUBKEY = b'\x02' + (b'\x55' * 32)
def psbt_field(key_type, value):
@@ -31,13 +28,11 @@ def previous_tx(txout):
return pack('<i', 2) + b'\x01' + txin + b'\x01' + txout.serialize() + pack('<I', 0)
-def make_input(witness_txout, non_witness_txout=None, redeem_script=None):
+def make_input(witness_txout, non_witness_txout=None):
data = b''
if non_witness_txout:
data += psbt_field(PSBT_IN_NON_WITNESS_UTXO, previous_tx(non_witness_txout))
data += psbt_field(PSBT_IN_WITNESS_UTXO, witness_txout.serialize())
- if redeem_script:
- data += psbt_field(PSBT_IN_REDEEM_SCRIPT, redeem_script)
return psbtInputProxy(BytesIO(data + b'\x00'), 0)
@@ -50,42 +45,27 @@ def assert_raises(exc_type, callback):
matching = CTxOut(1000, P2WPKH_SCRIPT)
-loaded = make_input(matching, matching).get_utxo(0)
+matching_input = make_input(matching, matching)
+loaded = matching_input.get_utxo(0)
assert loaded.nValue == matching.nValue
assert loaded.scriptPubKey == matching.scriptPubKey
+assert matching_input.is_segwit
amount_mismatch = make_input(CTxOut(999, P2WPKH_SCRIPT), matching)
assert_raises(AssertionError, lambda: amount_mismatch.get_utxo(0))
script_mismatch = make_input(CTxOut(1000, b'\x00\x14' + (b'\x33' * 20)), matching)
assert_raises(AssertionError, lambda: script_mismatch.get_utxo(0))
-legacy = make_input(CTxOut(1000, P2PKH_SCRIPT))
-legacy.subpaths[DUMMY_PUBKEY] = []
-assert_raises(FatalPSBTIssue, lambda: legacy.determine_my_signing_key(0, CTxOut(1000, P2PKH_SCRIPT), 0, None))
-native_segwit = make_input(CTxOut(1000, P2WPKH_SCRIPT))
-native_segwit.subpaths[DUMMY_PUBKEY] = []
-native_segwit.determine_my_signing_key(0, CTxOut(1000, P2WPKH_SCRIPT), 0, None)
-assert native_segwit.is_segwit
-
-wrapped_program = b'\x00\x14' + (b'\x44' * 20)
-wrapped_script = b'\xa9\x14' + hash160(wrapped_program) + b'\x87'
-wrapped_segwit = make_input(CTxOut(1000, wrapped_script), redeem_script=wrapped_program)
-wrapped_segwit.subpaths[DUMMY_PUBKEY] = []
-wrapped_segwit.determine_my_signing_key(0, CTxOut(1000, wrapped_script), 0, None)
-assert wrapped_segwit.is_segwit
-
-
-class FakeInput:
- def __init__(self, owned):
- self.owned = owned
+class FakeOwnedInput:
+ def __init__(self):
self.fully_signed = False
self.witness_utxo = True
self.utxo = None
self.required_key = None
- self.num_our_keys = 1 if owned else 0
- self.is_segwit = False
+ self.num_our_keys = 1
+ self.is_segwit = True
self.subpaths = {}
self.tap_subpaths = {}
@@ -97,7 +77,7 @@ def get_utxo(self, _idx):
def determine_my_signing_key(self, _idx, utxo, _xfp, _psbt):
self.amount = utxo.nValue
- self.required_key = b'key' if self.owned else None
+ self.required_key = b'key'
class FakePrevout:
@@ -109,8 +89,8 @@ class FakeTxIn:
class FakeInputPSBT:
- def __init__(self, owned):
- self.inputs = [FakeInput(owned)]
+ def __init__(self, psbt_input):
+ self.inputs = [psbt_input]
self.my_xfp = 0
self.total_value_in = None
self.fee_is_verified = True
@@ -122,13 +102,21 @@ def input_iter(self):
yield 0, FakeTxIn()
-external_input_psbt = FakeInputPSBT(False)
-psbtObject.consider_inputs(external_input_psbt)
-assert not external_input_psbt.fee_is_verified
+verified_amounts = []
+original_verify_amount = history.verify_amount
+history.verify_amount = lambda _prevout, amount, idx: verified_amounts.append((amount, idx))
+try:
+ external_input_psbt = FakeInputPSBT(make_input(CTxOut(2000, P2WPKH_SCRIPT)))
+ psbtObject.consider_inputs(external_input_psbt)
+ assert not external_input_psbt.fee_is_verified
+
+ owned_input_psbt = FakeInputPSBT(FakeOwnedInput())
+ psbtObject.consider_inputs(owned_input_psbt)
+ assert owned_input_psbt.fee_is_verified
+finally:
+ history.verify_amount = original_verify_amount
-owned_input_psbt = FakeInputPSBT(True)
-psbtObject.consider_inputs(owned_input_psbt)
-assert owned_input_psbt.fee_is_verified
+assert verified_amounts == [(2000, 0), (2000, 0)]
class FakeOutputProxy:
@@ -159,12 +147,12 @@ def consider_dangerous_change(self, _xfp):
pass
-unverified_fee_psbt = FakeOutputPSBT(False)
+unverified_fee_psbt = FakeOutputPSBT(external_input_psbt.fee_is_verified)
psbtObject.consider_outputs(unverified_fee_psbt)
assert unverified_fee_psbt.warnings[0][0] == 'Unverified Fee'
assert all(label not in {'Big Fee', 'Huge Fee'} for label, _text in unverified_fee_psbt.warnings)
-verified_fee_psbt = FakeOutputPSBT(True)
+verified_fee_psbt = FakeOutputPSBT(owned_input_psbt.fee_is_verified)
psbtObject.consider_outputs(verified_fee_psbt)
assert verified_fee_psbt.warnings[0][0] == 'Huge Fee'
Why this scored 59/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.