Mark coordinator-supplied change data as claimed
What changed, and why it matters
This commit is a code cleanup and documentation change, not a security fix. It renames variables and dictionary keys from 'fingerprint' and 'derivation_path' to 'claimed_fingerprint' and 'claimed_derivation_path' to make it clearer that data coming from a PSBT is coordinator-supplied and unverified. No program behavior changes; the same values flow the same way.
No action required. Treat as a maintainability/documentation improvement. Continue to verify that future commits actually enforce the documented invariant (re-derivation before assigning verified_ values).
Security signals we found
Renames unverified coordinator-supplied metadata keys to 'claimed_' prefix
Adds trust-boundary documentation and naming convention in class docstring
Explicitly states no logic changes in commit message
Evidence from the diff
The patch renames local variables and change_data keys in PSBTParser and updates consumers in psbt_views.py and tests. It adds a class docstring establishing a naming convention (claimed_ vs verified_) and an invariant that verified values must not be assigned from claimed values without re-derivation. The commit message explicitly states ‘No logic changes; the two dict keys are the only behavioral difference.’
Changed components
src/seedsigner/models/psbt_parser.pysrc/seedsigner/views/psbt_views.pytests/test_psbt_parser.pyInspect captured patch +47 / −27
### src/seedsigner/models/psbt_parser.py
@@ -27,10 +27,30 @@ class PSBTParser():
as change, the fee, where the spend is going, and any OP_RETURN payload.
Constructing it with a seed parses immediately. The results are read off the instance
- attributes, with per-change-output detail in change_data.
+ attributes, with per-output detail in change_data. Note that change_data and
+ change_amount cover every output coming back to this seed, self-transfers to a receive
+ address included, not just the ones on a change branch. The view layer tells the two
+ apart by the branch index in the derivation path.
has_matching_input_fingerprint answers the earlier question of which seed a psbt is
for and needs no parse.
+
+ A psbt is written by an untrusted coordinator. The metadata it carries about keys
+ (fingerprints, derivation paths, xpubs) is a claim, not a fact. The onus is on us to
+ verify by re-deriving from the signing seed. For multisig, verification depends on the
+ user providing a "known good" descriptor (i.e. can be trusted) from which we can
+ verify the outputs by deriving from the cosigners' xpubs.
+
+ This class makes the difference visible in its own names:
+
+ claimed_... coordinator-supplied metadata (fingerprints, derivation paths, xpubs).
+ Safe to read and display; never safe to make a decision on.
+ verified_... a fact this device proved by re-deriving from the signing seed and
+ matching real key material. Only assigned by code that performed that
+ derivation.
+
+ Invariant: no verified_ value is ever assigned from a claimed_ value without an
+ intervening re-derivation from self.root.
"""
# Upper bound on how many levels of derivation a single parse will cache. 1000 is
@@ -237,27 +257,27 @@ def _parse_outputs(self, child_key_derivation_cache: dict):
elif is_change:
addr = vout[i].script_pubkey.address(NETWORKS[SettingsConstants.map_network_to_embit(self.network)])
- fingerprints = []
- derivation_paths = []
+ claimed_fingerprints = []
+ claimed_derivation_paths = []
# extract info from non-taproot outputs
if len(self.psbt.outputs[i].bip32_derivations) > 0:
for d, derivation_path in self.psbt.outputs[i].bip32_derivations.items():
- fingerprints.append(hexlify(derivation_path.fingerprint).decode())
- derivation_paths.append(bip32.path_to_str(derivation_path.derivation))
+ claimed_fingerprints.append(hexlify(derivation_path.fingerprint).decode())
+ claimed_derivation_paths.append(bip32.path_to_str(derivation_path.derivation))
# extract info from taproot outputs
if len(self.psbt.outputs[i].taproot_bip32_derivations) > 0:
for d, (leaf_hashes, derivation) in self.psbt.outputs[i].taproot_bip32_derivations.items():
- fingerprints.append(hexlify(derivation.fingerprint).decode())
- derivation_paths.append(bip32.path_to_str(derivation.derivation))
+ claimed_fingerprints.append(hexlify(derivation.fingerprint).decode())
+ claimed_derivation_paths.append(bip32.path_to_str(derivation.derivation))
self.change_data.append({
"output_index": i,
"address": addr,
"amount": vout[i].value,
- "fingerprint": fingerprints,
- "derivation_path": derivation_paths,
+ "claimed_fingerprint": claimed_fingerprints,
+ "claimed_derivation_path": claimed_derivation_paths,
})
self.change_amount += vout[i].value
### src/seedsigner/views/psbt_views.py
@@ -116,16 +116,16 @@ def run(self):
{
'address': 'bc1q............',
'amount': 397621401,
- 'fingerprint': ['22bde1a9', '73c5da0a'],
- 'derivation_path': ['m/48h/1h/0h/2h/1/0', 'm/48h/1h/0h/2h/1/0']
+ 'claimed_fingerprint': ['22bde1a9', '73c5da0a'],
+ 'claimed_derivation_path': ['m/48h/1h/0h/2h/1/0', 'm/48h/1h/0h/2h/1/0']
}, {},
]
"""
num_change_outputs = 0
num_self_transfer_outputs = 0
for change_output in change_data:
- # print(f"""{change_output["derivation_path"][0]}""")
- if change_output["derivation_path"][0].split("/")[-2] == "1":
+ # print(f"""{change_output["claimed_derivation_path"][0]}""")
+ if change_output["claimed_derivation_path"][0].split("/")[-2] == "1":
num_change_outputs += 1
else:
num_self_transfer_outputs += 1
@@ -325,25 +325,25 @@ def run(self):
{
'address': 'bc1q............',
'amount': 397621401,
- 'fingerprint': ['22bde1a9', '73c5da0a'],
- 'derivation_path': ['m/48h/1h/0h/2h/1/0', 'm/48h/1h/0h/2h/1/0']
+ 'claimed_fingerprint': ['22bde1a9', '73c5da0a'],
+ 'claimed_derivation_path': ['m/48h/1h/0h/2h/1/0', 'm/48h/1h/0h/2h/1/0']
}
"""
# Single-sig verification is easy. We expect to find a single fingerprint
# and derivation path.
seed_fingerprint = self.controller.psbt_seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK))
- if seed_fingerprint not in change_data.get("fingerprint"):
+ if seed_fingerprint not in change_data.get("claimed_fingerprint"):
# TODO: Something is wrong with this psbt(?). Reroute to warning?
return Destination(NotYetImplementedView)
- i = change_data.get("fingerprint").index(seed_fingerprint)
- derivation_path = change_data.get("derivation_path")[i]
+ i = change_data.get("claimed_fingerprint").index(seed_fingerprint)
+ claimed_derivation_path = change_data.get("claimed_derivation_path")[i]
# 'm/84h/1h/0h/1/0' would be a change addr while 'm/84h/1h/0h/0/0' is a self-receive
- is_change_derivation_path = int(derivation_path.split("/")[-2]) == 1
- derivation_path_addr_index = int(derivation_path.split("/")[-1])
+ is_change_derivation_path = int(claimed_derivation_path.split("/")[-2]) == 1
+ derivation_path_addr_index = int(claimed_derivation_path.split("/")[-1])
if is_change_derivation_path:
# TRANSLATOR_NOTE: The amount you're receiving back from the transaction
@@ -384,8 +384,8 @@ def run(self):
script_type = pubkey.script_type()
# extract derivation path to get wallet and change derivation
- change_path = '/'.join(derivation_path.split("/")[-2:])
- wallet_path = '/'.join(derivation_path.split("/")[:-2])
+ change_path = '/'.join(claimed_derivation_path.split("/")[-2:])
+ wallet_path = '/'.join(claimed_derivation_path.split("/")[:-2])
xpub = self.controller.psbt_seed.get_xpub(
wallet_path=wallet_path,
@@ -425,7 +425,7 @@ def run(self):
amount=change_data.get("amount"),
is_multisig=psbt_parser.is_multisig,
fingerprint=seed_fingerprint,
- derivation_path=derivation_path,
+ derivation_path=claimed_derivation_path,
is_change_derivation_path=is_change_derivation_path,
derivation_path_addr_index=derivation_path_addr_index,
is_change_addr_verified=is_change_addr_verified,
### tests/test_psbt_parser.py
@@ -321,8 +321,8 @@ def test_p2tr_change_detection():
'output_index': 0,
'address': 'bcrt1prz4g6saush37epdwhvwpu78td3q7yfz3xxz37axlx7udck6wracq3rwq30',
'amount': 2871443918,
- 'fingerprint': ['394aed14'],
- 'derivation_path': ['m/86h/1h/0h/1/1']}
+ 'claimed_fingerprint': ['394aed14'],
+ 'claimed_derivation_path': ['m/86h/1h/0h/1/1']}
]
assert pp.spend_amount == 319049328
assert pp.change_amount == 2871443918
@@ -481,8 +481,8 @@ def test_parse_op_return_content():
'output_index': 0,
'address': 'bcrt1qvwkhakqhz7m7kmz6332avatsmdy32m644g86vv',
'amount': 99992296,
- 'fingerprint': ['0fb882ff'],
- 'derivation_path': ["m/84h/1h/0h/0/2"]}
+ 'claimed_fingerprint': ['0fb882ff'],
+ 'claimed_derivation_path': ["m/84h/1h/0h/0/2"]}
]
assert psbt_parser.spend_amount == 0 # This is a self-spend; no value being spent, other than the tx fee
assert psbt_parser.change_amount == 99992296Why this scored 15/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.