Admit bare p2sh outputs as nested single sig change
What changed, and why it matters
This commit fixes a bug in SeedSigner's PSBT transaction parser. When a user spent from a 'nested single-signature SegWit' wallet (p2sh-p2wpkh), a change output could omit an optional redeem script. The parser then misread the output as a plain, unrelated p2sh payment, so it showed the user's own change as money going to a stranger. Worse, an attacker could craft a PSBT that still claimed the change belonged to the user's seed while actually paying a different address, and the old code would not detect the contradiction. The patch lets these bare p2sh outputs through to the full ownership check, which rebuilds the expected address from the seed and catches any mismatch.
Treat this commit as a security fix and include it in the next release. Users should upgrade before signing p2sh-p2wpkh PSBTs, especially those produced by coordinators that omit the output redeem script. Review any past signed transactions from nested SegWit wallets where change was shown as an external spend.
Security signals we found
UI misrepresentation: user's own change displayed as external payment
Missing ownership-contradiction check on crafted PSBT output
Optional BIP-174 field used as policy discriminator, causing type mismatch
Patch adds explicit guard conditions (single derivation, verified, no multisig m field) before bypassing shape check
Tests cover both the benign and malicious cases
Evidence from the diff
The change renames _policy_shape_matches to _is_change_candidate and makes it an instance method. It now accepts a bare p2sh output as a change candidate when the inputs are p2sh-p2wpkh, the output policy has no m-of-n multisig fields, the output annotates exactly one bip32 derivation, and that derivation is verified for this seed. The subsequent rebuild-based ownership verification (unchanged) then either confirms honest change or raises PSBTOutputOwnershipContradictionError if the scriptPubKey was repointed. Two tests are added: one showing legitimate nested single-sig change without redeem script is counted as change, and one showing a repointed bare p2sh output that still claims the seed is rejected.
Changed components
src/seedsigner/models/psbt_parser.pytests/test_psbt_parser.pyInspect captured patch +77 / −15
### src/seedsigner/models/psbt_parser.py
@@ -249,7 +249,7 @@ def parse(self):
_get_policy doesn't propagate cosigner errors, so two such policies match
without anything having tied them to the same keys. TODO: don't let a
policy with no cosigner information pass as a match between inputs.
- Outputs deliberately compare shape alone; see _policy_shape_matches.
+ Outputs deliberately compare shape alone; see _is_change_candidate.
5. _parse_outputs: organizes the output data (amounts, destination_addresses,
etc.) and verifies the ownership of the outputs that come back to this seed
@@ -411,7 +411,7 @@ def _parse_outputs(self, child_key_derivation_cache: dict):
# Is this output change? If this output's policy is superficially similar to
# the spending wallet's policy (e.g. they're both 2-of-3 p2wsh), then it's a
# candidate for being change.
- if PSBTParser._policy_shape_matches(out_policy, self.policy):
+ if self._is_change_candidate(out, out_policy, self.verified_output_derivation_paths[i]):
# Begin the extensive work to fully verify whether this output is indeed
# change.
@@ -708,23 +708,42 @@ def _get_policy(scope, scriptpubkey, xpubs, child_key_derivation_cache: dict | N
return policy
- @staticmethod
- def _policy_shape_matches(policy_a: dict, policy_b: dict) -> bool:
+ def _is_change_candidate(self, out: OutputScope, out_policy: dict, verified_derivation_paths: List[DerivationPath]) -> bool:
"""
- Compares two policies on the shape of the script they describe: the script type,
- plus m-of-n for multisig.
-
- A policy can also carry the cosigners resolved from the coordinator's global
- xpubs. Those are never authoritative here, and comparing them would let a psbt
- decide which of its own outputs get verified: one misannotated fingerprint makes
- that output's cosigners fail to resolve, and the output then stops matching the
- inputs' policy. Shape comes from the scriptPubKey and the supplied script, and the
- caller proves ownership rather than assuming it.
+ Determines whether an output is worth the full ownership check in _parse_outputs.
+
+ Returns True if the output's policy has the same "shape" as the inputs' policy:
+ the script type, plus m-of-n for multisig.
+
+ One outlier: Nested single sig (p2sh-p2wpkh). Its scriptPubKey is a p2sh hash of
+ its redeem script, but per BIP-174 the redeem script itself is optional.
+ When it is omitted, the output is superficially indistinguishable from plain p2sh.
+ If the inputs are p2sh-p2wpkh, then such an output would fail the policy
+ comparison test (p2sh != p2sh-p2wpkh) when it may have actually been possible to
+ verify it as our change.
+
+ So instead, when a p2sh output could be our own nested single sig change we let it
+ through and leave it to the rebuild process to verify if the output really is our
+ change.
+
+ Note: A multisig's input or output policy can also include the cosigners if
+ they're supplied in the global xpubs. But this function does not take the
+ cosigners into account; cosigner information, if provided, is evaluated later.
"""
+ # The outlier: a single sig p2sh output when the inputs are p2sh-p2wpkh.
+ if (
+ self.policy["type"] == "p2sh-p2wpkh" # Input policy criteria
+ and out_policy["type"] == "p2sh" # Output policy criteria
+ and "m" not in out_policy # Exclude multisig
+ and len(out.bip32_derivations) == 1 # Nested single sig pays just one key
+ and len(verified_derivation_paths) == 1 # And that one key must be ours
+ ):
+ return True
+
+ # The usual test: the output's policy has the same shape as the inputs' policy.
for field in ("type", "m", "n"):
- if policy_a.get(field) != policy_b.get(field):
+ if out_policy.get(field) != self.policy.get(field):
return False
-
return True
### tests/test_psbt_parser.py
@@ -2062,6 +2062,49 @@ def test__parse__rejects_a_multisig_output_whose_supplied_script_is_not_its_own(
self._parse(psbt)
+ def test__parse__counts_nested_single_sig_change_without_its_redeem_script_as_change(self):
+ """
+ A legitimate nested single sig (p2sh-p2wpkh) change output can omit its redeem
+ script (BIP-174 makes it optional) while still claiming (via bip32_derivations)
+ that a key owned by our seed will receive the change.
+
+ But the parser's proof of ownership check does not care about the missing redeem
+ script: the parser rebuilds p2sh(p2wpkh(K)) from the seed's own key at the claimed
+ path regardless. So the output should still be verifiable as change.
+ """
+ psbt = self._psbt_with_change(PSBTTestData.SINGLE_SIG_NESTED_SEGWIT_1_INPUT, PSBTTestData.SINGLE_SIG_NESTED_SEGWIT_CHANGE)
+
+ # The output claims a single key and our seed really does derive it there.
+ assert len(psbt.outputs[0].bip32_derivations) == 1
+ public_key, derivation_path = list(psbt.outputs[0].bip32_derivations.items())[0]
+ assert PSBTParser.seed_owns_pubkey(self._root(), derivation_path.derivation, public_key, child_key_derivation_cache=None) is True
+
+ psbt.outputs[0].redeem_script = None
+
+ psbt_parser = self._parse(psbt)
+ assert psbt_parser.change_amount == 10_000
+ assert psbt_parser.spend_amount == 0
+
+
+ def test__parse__rejects_a_bare_p2sh_output_that_claims_this_seed_but_pays_someone_else(self):
+ """
+ Variation on the prior test: the redeem script is still omitted but this time the
+ psbt repoints its output at a stranger's p2sh-p2wpkh. Crucially, the output keeps
+ its claim on this seed, making this an attempt at deception (if there was no claim
+ on the output, it would simply be a typical external spend output).
+
+ When the parser rebuilds p2sh(p2wpkh(K)), the resulting scriptPubKey will not
+ match what the output commits to. The psbt should be refused with
+ PSBTOutputOwnershipContradictionError.
+ """
+ psbt = self._psbt_with_change(PSBTTestData.SINGLE_SIG_NESTED_SEGWIT_1_INPUT, PSBTTestData.SINGLE_SIG_NESTED_SEGWIT_CHANGE)
+ psbt.outputs[0].redeem_script = None
+ psbt.outputs[0].script_pubkey = script.p2sh(script.p2wpkh(foreign_public_key()))
+
+ with pytest.raises(PSBTOutputOwnershipContradictionError):
+ self._parse(psbt)
+
+
def test_get_cosigners_returns_a_sorted_list(self):
"""
Two multisig scripts can list the same wallet's keys in different orders, so theWhy this scored 68/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.