Merge pull request #689 from Foundation-Devices/fix/psbt-witness-iter
What changed, and why it matters
This firmware update tightens how Passport handles Bitcoin transaction files (PSBTs). It now rejects unsigned transactions that incorrectly include 'witness' data—extra proof data that belongs only in signed transactions. Before, such malformed files caused a confusing internal error instead of a clean user-facing rejection. The change also removes dead code that tried to preserve witness data that should never have been present. This is a defensive hardening fix: it prevents a malformed PSBT from reaching deeper transaction logic and gives the user a clearer error message.
Treat as a low-to-moderate hardening fix. Review whether any downstream code or user flows depended on the old had_witness behavior, and ensure the new FatalPSBTIssue is surfaced appropriately in the UI. No urgent incident response is indicated by the diff alone.
Security signals we found
Input validation hardening for PSBT unsigned transaction parsing
Replacement of internal ValueError with explicit FatalPSBTIssue for malformed witness data
Removal of unreachable/dead witness-preservation branch
Added unit test covering witness rejection and iterator correctness
Evidence from the diff
The commit modifies psbt.py’s parse_txn() and input_witness_iter() to enforce BIP-174’s rule that the unsigned transaction in a PSBT must be serialized without witness data. Previously, parse_txn() detected the segwit marker/flags, set self.had_witness, and later skipped over CTxInWitness objects; if a witness was present, _skip_n_objs() raised a bare ValueError(‘CTxInWitness’). Now, a marker==0 && flags!=0 condition raises FatalPSBTIssue(‘Unsigned transaction must not include witness data’). The had_witness and wit_start state fields are removed, input_witness_iter() always yields fresh empty CTxInWitness placeholders, and finalize() decides witness inclusion solely from input.is_segwit. A unit test is added to verify rejection of witness-serialized unsigned txns and correct iterator behavior.
Changed components
ports/stm32/boards/Passport/modules/psbt.pyports/stm32/boards/Passport/modules/tests/test_unit.pyports/stm32/boards/Passport/modules/tests/unit/psbt_unsigned_txn.pyInspect captured patch +112 / −32
### ports/stm32/boards/Passport/modules/psbt.py
@@ -990,12 +990,10 @@ def __init__(self):
# details that we discover as we go
self.inputs = None
self.outputs = None
- self.had_witness = None
self.num_inputs = None
self.num_outputs = None
self.vin_start = None
self.vout_start = None
- self.wit_start = None
self.txn_version = None
self.lock_time = None
self.total_value_out = None
@@ -1079,13 +1077,18 @@ def parse_txn(self):
# don't force that
self.txn_version, marker, flags = unpack("<iBB", fd.read(6))
- self.had_witness = (marker == 0 and flags != 0x0)
assert self.txn_version in {1, 2}, "bad txn version"
- if not self.had_witness:
- # rewind back over marker+flags
- fd.seek(-2, 1)
+ # BIP-174 requires the unsigned transaction to be serialized without witness
+ # data. A zero marker can only be the segwit marker here, since an input count
+ # of zero is rejected below. This already failed, but as a bare ValueError from
+ # _skip_n_objs(), which has no pattern for 'CTxInWitness'.
+ if marker == 0 and flags != 0x0:
+ raise FatalPSBTIssue('Unsigned transaction must not include witness data')
+
+ # rewind back over marker+flags
+ fd.seek(-2, 1)
num_in = deser_compact_size(fd)
assert num_in > 0, "no ins?"
@@ -1104,13 +1107,6 @@ def parse_txn(self):
end_pos = sum(self.txn)
- # remainder is the witness data, and then the lock time
-
- if self.had_witness:
- # we'll need to come back to this pos if we
- # want to read the witness data later.
- self.wit_start = _skip_n_objs(fd, num_in, 'CTxInWitness')
-
# we are at end of outputs, and no witness data, so locktime is here
self.lock_time = unpack("<I", fd.read(4))[0]
@@ -1142,23 +1138,10 @@ def input_iter(self):
fd.seek(cont)
def input_witness_iter(self):
- # yield all the witness data, in order by input
- if not self.had_witness:
- # original txn had no witness data, so provide placeholder objs
- for in_idx in range(self.num_inputs):
- yield in_idx, CTxInWitness()
- return
-
- fd.seek(self.wit_start)
- for idx in range(num_in):
-
- wit = CTxInWitness()
- wit.deserialize(fd)
-
- cont = fd.tell()
- yield idx, wit
-
- fd.seek(cont)
+ # yield a placeholder witness for each input: parse_txn() rejects a witness
+ # serialized unsigned txn, so there is never any witness data to preserve
+ for in_idx in range(self.num_inputs):
+ yield in_idx, CTxInWitness()
def guess_M_of_N(self):
# Peek at the inputs to see if we can guess M/N value. Just takes
@@ -1867,9 +1850,8 @@ def finalize(self, fd):
fd.write(pack('<i', self.txn_version)) # nVersion
# does this txn require witness data to be included?
- # - yes, if the original txn had some
# - yes, if we did a segwit signature on any input
- needs_witness = self.had_witness or any(i.is_segwit for i in self.inputs if i)
+ needs_witness = any(i.is_segwit for i in self.inputs if i)
if needs_witness:
# zero marker, and flags=0x01
### ports/stm32/boards/Passport/modules/tests/test_unit.py
@@ -48,6 +48,10 @@ def test_hdnode_blank(test):
assert test('hdnode_blank.py') == b'OK'
+def test_psbt_unsigned_txn(test):
+ assert test('psbt_unsigned_txn.py') == b'OK'
+
+
def test_psbt_multisig_approval(test):
assert test('psbt_multisig_approval.py') == b'OK'
### ports/stm32/boards/Passport/modules/tests/unit/psbt_unsigned_txn.py
@@ -0,0 +1,94 @@
+# SPDX-FileCopyrightText: © 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# BIP-174 requires the unsigned transaction inside a PSBT to be serialized without
+# witness data. Passport rejects anything else, so it never has witness data of its
+# own to preserve and always fills the witness area itself when finalizing.
+
+from uio import BytesIO
+from ustruct import pack
+
+from exceptions import FatalPSBTIssue
+from psbt import psbtObject
+from serializations import COutPoint, CTxIn, CTxInWitness, CTxOut, ser_compact_size
+
+P2WPKH_SCRIPT = b'\x00\x14' + (b'\x11' * 20)
+OUTPUT_VALUE = 1000
+
+
+class MockPSBT:
+ '''Just enough of a psbtObject for parse_txn() and the iterators to work on.'''
+
+ def __init__(self, raw):
+ self.fd = BytesIO(raw)
+ self.txn = (0, len(raw))
+ self.total_value_out = None
+
+
+def ser_unsigned_txn(num_in=1, num_out=1, version=2, witness=False):
+ body = pack('<i', version)
+
+ if witness:
+ body += b'\x00\x01'
+
+ body += ser_compact_size(num_in)
+ for idx in range(num_in):
+ body += CTxIn(COutPoint(idx + 1, idx)).serialize()
+
+ body += ser_compact_size(num_out)
+ for _ in range(num_out):
+ body += CTxOut(OUTPUT_VALUE, P2WPKH_SCRIPT).serialize()
+
+ if witness:
+ for _ in range(num_in):
+ body += CTxInWitness().serialize()
+
+ body += pack('<I', 0)
+ return body
+
+
+def must_raise(exc_type, call):
+ try:
+ call()
+ except exc_type:
+ return
+
+ raise RuntimeError('expected {}'.format(exc_type.__name__))
+
+
+# A compliant unsigned transaction parses, and the positions it records let the
+# input and output iterators walk it.
+psbt = MockPSBT(ser_unsigned_txn(num_in=2, num_out=3))
+psbtObject.parse_txn(psbt)
+
+assert psbt.txn_version == 2
+assert psbt.num_inputs == 2
+assert psbt.num_outputs == 3
+assert psbt.lock_time == 0
+
+assert [idx for idx, _ in psbtObject.input_iter(psbt)] == [0, 1]
+assert [txo.nValue for _, txo in psbtObject.output_iter(psbt)] == [OUTPUT_VALUE] * 3
+
+# Witness serialization is rejected with a message a caller can show, rather than
+# the bare ValueError('CTxInWitness') that _skip_n_objs() used to raise once the
+# parser reached the witness area.
+must_raise(FatalPSBTIssue,
+ lambda: psbtObject.parse_txn(MockPSBT(ser_unsigned_txn(witness=True))))
+
+# A zero input count is indistinguishable from the segwit marker, so it is caught on
+# that path rather than by the 'no ins?' assertion. Either way it is rejected.
+must_raise(FatalPSBTIssue,
+ lambda: psbtObject.parse_txn(MockPSBT(ser_unsigned_txn(num_in=0))))
+
+# Version checking is unaffected.
+must_raise(AssertionError,
+ lambda: psbtObject.parse_txn(MockPSBT(ser_unsigned_txn(version=3))))
+
+# finalize() fills the witness area itself, so every input gets a fresh empty
+# witness it can assign a stack to.
+witnesses = list(psbtObject.input_witness_iter(psbt))
+assert [idx for idx, _ in witnesses] == [0, 1]
+assert all(wit.scriptWitness.stack == [] for _, wit in witnesses)
+assert witnesses[0][1] is not witnesses[1][1]
+
+return_value.write(b'OK')Why this scored 47/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.