Reuse sensitive values during PSBT validation
What changed, and why it matters
This firmware update changes how Passport validates Bitcoin transaction files (PSBTs) before signing. It makes two main improvements: it reuses the wallet's secret seed for fewer key-derivation operations, and it verifies that the claimed input amounts are backed by real signing keys before trusting those amounts. The commit message and code comments suggest this closes a gap where a malicious or malformed PSBT could trick the device into recording an incorrect input amount in its history cache, which could later mislead the user about fees or balances. A second change also prevents re-signing a transaction that already has a signature, which could reduce certain replay or state-confusion risks.
Treat this commit as a security-hardening fix and include it in the next firmware release. Review whether the amount-cache poisoning scenario could have been exploited in practice and consider issuing a security note if user funds or fee verification could have been affected. No CVE is referenced in the commit; evaluate whether one should be requested.
Security signals we found
Deferred and batched sensitive key derivation during PSBT validation
Amount-cache update moved after ownership/key-path proof
New assertion to block re-signing already-signed non-multisig inputs
Expanded unit tests for multisig, taproot, and missing signing paths
Code comments explicitly describe preventing invalid metadata from being recorded
Evidence from the diff
The patch modifies PSBT input validation in psbt.py and sign_psbt_task.py. In consider_inputs(), derivation of signing nodes for witness-only UTXOs is now batched and deferred until after all inputs are inspected, using a single SensitiveValues context (register=True) rather than opening one per input. The history.verify_amount() cache update is moved to occur only after all claimed owned inputs have proved their signing keys, so a PSBT with forged ownership metadata cannot poison the amount cache. get_signing_node() now registers derived nodes (register=True) so they can be reused. In sign_psbt_task.py, the assertion that a non-multisig input is not already signed is moved before the private key is fetched, preventing re-derivation/re-signing of already-signed inputs. Unit tests are expanded to cover multisig, taproot, and missing-path cases.
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 +72 / −21
### ports/stm32/boards/Passport/modules/psbt.py
@@ -863,7 +863,7 @@ def get_signing_node(self, sv, my_xfp, my_idx):
# 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)
+ node = sv.derive_path(skp, register=True)
if node.public_key() == which_key:
return node, which_key
@@ -875,13 +875,13 @@ def get_signing_node(self, sv, my_xfp, my_idx):
(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)
+ node = sv.derive_path(skp, register=True)
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)
+ node = sv.derive_path(skp, register=True)
pubkey = node.public_key()[1:]
else:
raise AssertionError("Input #%d has no Passport signing path." % my_idx)
@@ -1445,6 +1445,7 @@ def consider_inputs(self):
# Important: parse incoming UTXO to build total input value
missing = 0
total_in = 0
+ witness_inputs_to_verify = []
for i, txi in self.input_iter():
gc.collect()
@@ -1474,23 +1475,33 @@ def consider_inputs(self):
inp.determine_my_signing_key(i, utxo, self.my_xfp, self)
if inp.witness_utxo and not inp.utxo:
+ # A false amount for an input we prove we sign makes our
+ # signature invalid, and the history cache catches changed
+ # amounts across attempts. Derivation proves the PSBT's
+ # ownership metadata before that argument is applied.
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)
+ witness_inputs_to_verify.append(i)
else:
self.fee_is_verified = False
gc.collect()
- # iff to UTXO is segwit, then check it's value, and also
- # capture that value, since it's supposed to be immutable
+ del utxo
+
+ if witness_inputs_to_verify:
+ import stash
+ with stash.SensitiveValues() as sv:
+ for i in witness_inputs_to_verify:
+ self.inputs[i].get_signing_node(sv, self.my_xfp, i)
+
+ # Only update the amount cache after all claimed owned inputs have
+ # proved their signing keys, so rejected metadata cannot be recorded.
+ for i, txi in self.input_iter():
+ inp = self.inputs[i]
if inp.is_segwit:
history.verify_amount(txi.prevout, inp.amount, i)
gc.collect()
- del utxo
-
gc.collect()
# XXX scan witness data provided, and consider those ins signed if not multisig?
### ports/stm32/boards/Passport/modules/tasks/sign_psbt_task.py
@@ -46,6 +46,10 @@ async def sign_psbt_task(on_done, psbt):
# but in other cases, no more signatures are possible
continue
+ if not inp.is_multisig:
+ assert not (inp.added_sig or inp.tap_key_sig), \
+ "This transaction has already been signed"
+
txi.scriptSig = inp.scriptSig
if not txi.scriptSig:
raise AssertionError('No scriptsig?')
@@ -63,10 +67,6 @@ async def sign_psbt_task(on_done, psbt):
node, which_key = inp.get_signing_node(sv, psbt.my_xfp, 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
@@ -22,6 +22,11 @@
MY_XFP = 0x12345678
OWNED_PUBKEY = b'\x02' + (b'\x55' * 32)
FORGED_PUBKEY = b'\x03' + (b'\x66' * 32)
+TAPROOT_PUBKEY = b'\x77' * 32
+DERIVED_PUBKEYS = {
+ 'm/0': OWNED_PUBKEY,
+ 'm/1': b'\x02' + TAPROOT_PUBKEY,
+}
def psbt_field(key_type, value, key=b''):
@@ -80,9 +85,11 @@ class FakeTxIn:
class FakeNode:
- @staticmethod
- def public_key():
- return OWNED_PUBKEY
+ def __init__(self, public_key):
+ self._public_key = public_key
+
+ def public_key(self):
+ return self._public_key
class FakeSensitiveValues:
@@ -93,10 +100,13 @@ def __exit__(self, _exc_type, _exc, _traceback):
pass
@staticmethod
- def derive_path(path, register=False):
- assert path == 'm/0'
- assert not register
- return FakeNode()
+ def derive_path(path, register=True):
+ assert register
+ return FakeNode(DERIVED_PUBKEYS[path])
+
+
+class FakeSigningInput:
+ pass
class FakeInputPSBT:
@@ -141,6 +151,36 @@ def input_iter(self):
assert verified_amounts == [(2000, 0), (2000, 0)]
+multisig_input = FakeSigningInput()
+multisig_input.is_multisig = True
+multisig_input.required_key = {OWNED_PUBKEY}
+multisig_input.subpaths = {OWNED_PUBKEY: [MY_XFP, 0]}
+node, which_key = psbtInputProxy.get_signing_node(
+ multisig_input, FakeSensitiveValues(), MY_XFP, 0)
+assert node.public_key() == OWNED_PUBKEY
+assert which_key == OWNED_PUBKEY
+
+taproot_input = FakeSigningInput()
+taproot_input.is_multisig = False
+taproot_input.required_key = TAPROOT_PUBKEY
+taproot_input.subpaths = {}
+taproot_input.tap_subpaths = {TAPROOT_PUBKEY: ([MY_XFP, 1], [])}
+node, which_key = psbtInputProxy.get_signing_node(
+ taproot_input, FakeSensitiveValues(), MY_XFP, 0)
+assert node.public_key()[1:] == TAPROOT_PUBKEY
+assert which_key == TAPROOT_PUBKEY
+
+missing_path_input = FakeSigningInput()
+missing_path_input.is_multisig = False
+missing_path_input.required_key = OWNED_PUBKEY
+missing_path_input.subpaths = {}
+missing_path_input.tap_subpaths = {}
+assert_raises(
+ AssertionError,
+ lambda: psbtInputProxy.get_signing_node(
+ missing_path_input, FakeSensitiveValues(), MY_XFP, 0),
+)
+
class FakeOutputProxy:
is_change = FalseWhy this scored 60/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.