mirror of
https://github.com/SeedSigner/seedsigner.git
synced 2026-10-05 15:08:25 +00:00
Merge pull request #1032 from kdmukai/psbt_output_ownership
[security] Verify that change outputs actually pay this seed
This commit is contained in:
@@ -51,6 +51,58 @@ class PSBTInputOwnershipClaimError(PSBTVerificationError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PSBTSurplusDerivationPathsError(PSBTVerificationError):
|
||||||
|
"""
|
||||||
|
Raised for three similar cases:
|
||||||
|
* single sig: output has more than one derivation path entry.
|
||||||
|
* multisig: an output confirmed to pay this seed claims more derivation path
|
||||||
|
entries than its committed script has keys.
|
||||||
|
* taproot: output has more than one derivation path entry claiming to be its
|
||||||
|
internal key (i.e. claiming no leaf hashes).
|
||||||
|
|
||||||
|
For single sig this is clearly a structural error and is not expected to be seen in
|
||||||
|
the real world but it's worth the sanity check.
|
||||||
|
|
||||||
|
For multisig, this could be an attempt to deceive the user, but we do not try to
|
||||||
|
adjudicate that.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PSBTMixedDerivationPathTypesError(PSBTVerificationError):
|
||||||
|
"""
|
||||||
|
An input or output declares derivation paths in both the ecdsa and the taproot key
|
||||||
|
maps (bip32_derivations and taproot_bip32_derivations).
|
||||||
|
|
||||||
|
This is a correctness problem (not expected to be seen in the real world) or
|
||||||
|
potentially a weak form of deception, but we do not try to adjudicate that.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PSBTOutputOwnershipContradictionError(PSBTVerificationError):
|
||||||
|
"""
|
||||||
|
The psbt's account of who an output pays contradicts which key(s) the output
|
||||||
|
actually commits to.
|
||||||
|
|
||||||
|
For multisig, it's one of:
|
||||||
|
* The output claims one of our keys, but the script the psbt supplied for it does
|
||||||
|
not match what the output actually commits to (the output's scriptPubKey).
|
||||||
|
* The output claims that one of our keys is part of the multisig that owns the
|
||||||
|
output, but that key is not part of the scriptPubKey commitment.
|
||||||
|
* Our seed owns a key that is part of the scriptPubKey commitment, but the output
|
||||||
|
claims a different seed in our place, by fingerprint or by listed public key.
|
||||||
|
|
||||||
|
Single sig supplies no script, so both contradictions come from the rebuild alone: the
|
||||||
|
output claims our key but commits to another key, or it commits to our key while
|
||||||
|
claiming a different fingerprint in our place.
|
||||||
|
|
||||||
|
|
||||||
|
We treat any of these deceptions as an attack.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PSBTSeedCannotSignError(PSBTVerificationError):
|
class PSBTSeedCannotSignError(PSBTVerificationError):
|
||||||
"""
|
"""
|
||||||
The selected seed holds no key that could sign any input.
|
The selected seed holds no key that could sign any input.
|
||||||
@@ -186,12 +238,18 @@ class PSBTParser():
|
|||||||
- multisig, cosigners unresolved: script type and m-of-n only.
|
- multisig, cosigners unresolved: script type and m-of-n only.
|
||||||
_get_policy doesn't propagate cosigner errors, so two such policies match
|
_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
|
without anything having tied them to the same keys. TODO: don't let a
|
||||||
policy with no cosigner information pass as a match.
|
policy with no cosigner information pass as a match between inputs.
|
||||||
|
Outputs deliberately compare shape alone; see _policy_shape_matches.
|
||||||
|
|
||||||
5. _parse_outputs: works out which outputs come back to this seed. For
|
5. _parse_outputs: organizes the output data (amounts, destination_addresses,
|
||||||
single-sig this proves the output script derives from the seed at the
|
etc.) and verifies the ownership of the outputs that come back to this seed
|
||||||
claimed path. TODO: reject outputs at a path the user's wallet would never
|
via:
|
||||||
scan.
|
- single-sig: Rebuild the output script from the seed and match it against
|
||||||
|
the committed scriptPubKey.
|
||||||
|
- multisig: Match the seed's verified key against the pubkeys in the script
|
||||||
|
the output commits to.
|
||||||
|
Every change_data entry after this point will carry a derivation path that
|
||||||
|
our seed provably owns.
|
||||||
|
|
||||||
Optimization via child_key_derivation_cache:
|
Optimization via child_key_derivation_cache:
|
||||||
Parsing traverses a derivation path down to an individual address one level at a
|
Parsing traverses a derivation path down to an individual address one level at a
|
||||||
@@ -266,7 +324,61 @@ class PSBTParser():
|
|||||||
if self.policy != inp_policy:
|
if self.policy != inp_policy:
|
||||||
raise RuntimeError("Mixed inputs in the transaction")
|
raise RuntimeError("Mixed inputs in the transaction")
|
||||||
|
|
||||||
|
|
||||||
def _parse_outputs(self, child_key_derivation_cache: dict):
|
def _parse_outputs(self, child_key_derivation_cache: dict):
|
||||||
|
"""
|
||||||
|
Sorts each output into change coming back to this seed, an external spend, or
|
||||||
|
OP_RETURN data, and totals the amounts for each. Note that self-transfer/receive
|
||||||
|
outputs are also considered "change".
|
||||||
|
|
||||||
|
Most of the work here is sorting through the psbt's claims about which, if any,
|
||||||
|
outputs are paying a key that can be derived from our seed, and then doing all
|
||||||
|
possible independent verifications for the given output data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
"""********************* How output ownership is determined *********************
|
||||||
|
Many outputs are obviously NOT ours. An output is only considered possible
|
||||||
|
change if its policy matches the inputs' policy "shape" (script type, plus m-of-n
|
||||||
|
for multisig; see parse()); anything else is recorded as an external spend.
|
||||||
|
|
||||||
|
The psbt will usually annotate which key(s) a change output pays (see embit's
|
||||||
|
bip32_derivations and taproot_bip32_derivations), but this is just a claim
|
||||||
|
supplied by the coordinator. These annotations are not authoritative. But such
|
||||||
|
claims are significant; if our checks prove that the claim is false, we consider
|
||||||
|
the deception an attack.
|
||||||
|
|
||||||
|
-- Proving the claim --
|
||||||
|
The output's scriptPubKey determines where the value ACTUALLY goes. But the
|
||||||
|
scriptPubKey only contains a hash of the spending conditions (note: taproot uses a
|
||||||
|
tweaked key instead), so we can't simply inspect the scriptPubKey to determine if
|
||||||
|
the output is ours.
|
||||||
|
|
||||||
|
We must build our own version of the scriptPubKey via:
|
||||||
|
* single sig: derive a key from our seed using the claimed derivation path.
|
||||||
|
* multisig: hash the claimed witness_script or redeem_script, then check that a
|
||||||
|
key derived from our seed is among that script's keys.
|
||||||
|
|
||||||
|
That leaves us holding two independent answers about the same output: which key it
|
||||||
|
commits to (our rebuild, matched against the scriptPubKey), and which key the psbt
|
||||||
|
says it commits to (the claim). We evaluate the output on those two facts:
|
||||||
|
|
||||||
|
| claims this seed | doesn't claim this seed
|
||||||
|
-------------------------+----------------------+-------------------------
|
||||||
|
commits to our key | presumed change | contradiction
|
||||||
|
commits to another key | contradiction | presumed external spend
|
||||||
|
|
||||||
|
If our two answers contradict each other, the psbt has been caught in a deception.
|
||||||
|
We raise an exception and reject the psbt.
|
||||||
|
|
||||||
|
Multisig change can't be fully verified until later in the process, so we use
|
||||||
|
"presumed" to avoid conveying a false impression of certainty. Single sig carries
|
||||||
|
its own note later in this function about its guarantees.
|
||||||
|
|
||||||
|
(note one exception: no taproot mismatch is rejected. A script tree tweaks our
|
||||||
|
internal key, so an honest taproot change output fails to match too, and we cannot
|
||||||
|
yet tell that apart from an output that claims our key but pays someone else. All
|
||||||
|
taproot mismatches pass as EXTERNAL spends and are never considered "change".)
|
||||||
|
******************************************************************************"""
|
||||||
self.spend_amount = 0
|
self.spend_amount = 0
|
||||||
self.change_amount = 0
|
self.change_amount = 0
|
||||||
self.change_data = []
|
self.change_data = []
|
||||||
@@ -281,90 +393,220 @@ class PSBTParser():
|
|||||||
|
|
||||||
for i, out in enumerate(self.psbt.outputs):
|
for i, out in enumerate(self.psbt.outputs):
|
||||||
out_policy = PSBTParser._get_policy(out, vout[i].script_pubkey, self.psbt.xpubs, child_key_derivation_cache)
|
out_policy = PSBTParser._get_policy(out, vout[i].script_pubkey, self.psbt.xpubs, child_key_derivation_cache)
|
||||||
is_change = False
|
is_presumed_change = False
|
||||||
|
|
||||||
# if policy is the same - probably change
|
# Is this output change? If this output's policy is superficially similar to
|
||||||
if out_policy == self.policy:
|
# the spending wallet's policy (e.g. they're both 2-of-3 p2wsh), then it's a
|
||||||
# double-check that it's change
|
# candidate for being change.
|
||||||
# we already checked in get_cosigners and parse_multisig
|
if PSBTParser._policy_shape_matches(out_policy, self.policy):
|
||||||
# that pubkeys are generated from cosigners,
|
# Begin the extensive work to fully verify whether this output is indeed
|
||||||
# and witness script is corresponding multisig
|
# change.
|
||||||
# so we only need to check that scriptpubkey is generated from
|
|
||||||
# witness script
|
|
||||||
|
|
||||||
# empty script by default
|
# Each of these is a claim we build our proof from, then keep for the
|
||||||
sc = script.Script(b"")
|
# follow-up check its signature type needs:
|
||||||
|
# * Single sig: the derivation path the seed derives a key at.
|
||||||
|
# * Multisig: the witness or redeem script the coordinator supplied.
|
||||||
|
# Only one of these will be needed, depending on the output type.
|
||||||
|
singlesig_derivation_path = None
|
||||||
|
multisig_script = None
|
||||||
|
|
||||||
|
# Compared against the output's real scriptPubKey below
|
||||||
|
rebuilt_script_pubkey = script.Script(b"")
|
||||||
|
|
||||||
# multisig, we know witness script
|
# multisig, we know witness script
|
||||||
if self.policy["type"] == "p2wsh":
|
if self.policy["type"] == "p2wsh":
|
||||||
sc = script.p2wsh(out.witness_script)
|
multisig_script = out.witness_script
|
||||||
|
rebuilt_script_pubkey = script.p2wsh(multisig_script)
|
||||||
|
|
||||||
elif self.policy["type"] == "p2sh-p2wsh":
|
elif self.policy["type"] == "p2sh-p2wsh":
|
||||||
sc = script.p2sh(script.p2wsh(out.witness_script))
|
multisig_script = out.witness_script
|
||||||
|
rebuilt_script_pubkey = script.p2sh(script.p2wsh(multisig_script))
|
||||||
|
|
||||||
# Arbitrary p2sh; includes pre-segwit multisig (m/45')
|
# Arbitrary p2sh; includes pre-segwit multisig (m/45')
|
||||||
elif self.policy["type"] == "p2sh":
|
elif self.policy["type"] == "p2sh":
|
||||||
sc = script.p2sh(out.redeem_script)
|
multisig_script = out.redeem_script
|
||||||
|
rebuilt_script_pubkey = script.p2sh(multisig_script)
|
||||||
|
|
||||||
# single-sig: p2pkh, p2sh-p2wpkh, and p2wpkh; taproot handled separately
|
# single-sig; taproot handled separately below.
|
||||||
# below.
|
elif self.policy["type"] in ("p2pkh", "p2sh-p2wpkh", "p2wpkh"):
|
||||||
elif "pkh" in self.policy["type"]:
|
# Sanity check; a single sig output shouldn't have multiple derivation
|
||||||
my_pubkey = None
|
# paths.
|
||||||
|
if len(out.bip32_derivations) > 1:
|
||||||
|
raise PSBTSurplusDerivationPathsError("Single-key output claims more than one derivation path")
|
||||||
|
|
||||||
# should be one or zero for single-key addresses
|
# Rebuild the scriptPubKey from the key at the claimed derivation path
|
||||||
if len(out.bip32_derivations.values()) > 0:
|
if len(out.bip32_derivations.values()) == 1:
|
||||||
der = list(out.bip32_derivations.values())[0].derivation
|
singlesig_derivation_path = list(out.bip32_derivations.values())[0].derivation
|
||||||
my_pubkey = PSBTParser._derive_with_cache(self.root, der, child_key_derivation_cache)
|
seed_public_key = PSBTParser._derive_with_cache(self.root, singlesig_derivation_path, child_key_derivation_cache).get_public_key()
|
||||||
|
rebuilt_script_pubkey = PSBTParser._build_singlesig_script(self.policy["type"], seed_public_key)
|
||||||
|
else:
|
||||||
|
# There's nothing for us to verify against so this output will be
|
||||||
|
# considered an external spend.
|
||||||
|
pass
|
||||||
|
|
||||||
if self.policy["type"] == "p2pkh" and my_pubkey is not None:
|
elif self.policy["type"] == "p2tr":
|
||||||
sc = script.p2pkh(my_pubkey)
|
taproot_entries = list(out.taproot_bip32_derivations.values())
|
||||||
|
|
||||||
elif self.policy["type"] == "p2sh-p2wpkh" and my_pubkey is not None:
|
if len(taproot_entries) == 0:
|
||||||
sc = script.p2sh(script.p2wpkh(my_pubkey))
|
# There's nothing for us to verify against so this output will be
|
||||||
|
# considered an external spend.
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# A taproot output has exactly one internal key. So an output
|
||||||
|
# should not claim multiple derivation path entries for the
|
||||||
|
# internal key. However, taproot outputs can have additional
|
||||||
|
# entries for keys in script tree leaves. So we count just the
|
||||||
|
# internal key claims:
|
||||||
|
internal_key_claims = sum(1 for leaf_hashes, _ in taproot_entries if not leaf_hashes)
|
||||||
|
if internal_key_claims > 1:
|
||||||
|
raise PSBTSurplusDerivationPathsError("Taproot output claims more than one internal key")
|
||||||
|
|
||||||
elif self.policy["type"] == "p2wpkh" and my_pubkey is not None:
|
if len(taproot_entries) == 1 and internal_key_claims == 1:
|
||||||
sc = script.p2wpkh(my_pubkey)
|
leaf_hashes, derivation = taproot_entries[0]
|
||||||
|
singlesig_derivation_path = derivation.derivation
|
||||||
|
seed_public_key = PSBTParser._derive_with_cache(self.root, singlesig_derivation_path, child_key_derivation_cache).get_public_key()
|
||||||
|
rebuilt_script_pubkey = PSBTParser._build_singlesig_script(self.policy["type"], seed_public_key)
|
||||||
|
else:
|
||||||
|
# This output has at least one derivation path entry for a key
|
||||||
|
# in a script tree leaf. But since we don't yet parse the
|
||||||
|
# script tree, we can't reconstruct the output's correct
|
||||||
|
# scriptPubKey. So this output will fail to match its
|
||||||
|
# scriptPubKey below, at which point it will be considered an
|
||||||
|
# external spend. This is the best we can do when we cannot
|
||||||
|
# verify ownership.
|
||||||
|
# TODO: Support keys in script tree leaves
|
||||||
|
pass
|
||||||
|
|
||||||
elif "p2tr" in self.policy["type"]:
|
else:
|
||||||
my_pubkey = None
|
# Safety catch-all: any new script types will need explicit handling
|
||||||
# should have one or zero derivations for single-key addresses
|
# above. Note that embit reports unrecognized script types as `None`,
|
||||||
if len(out.taproot_bip32_derivations.values()) > 0:
|
# which is also caught here.
|
||||||
# TODO: Support keys in taptree leaves
|
raise RuntimeError(f"Unsupported policy type: {self.policy['type']}")
|
||||||
leaf_hashes, derivation = list(out.taproot_bip32_derivations.values())[0]
|
|
||||||
der = derivation.derivation
|
|
||||||
my_pubkey = PSBTParser._derive_with_cache(self.root, der, child_key_derivation_cache)
|
|
||||||
sc = script.p2tr(my_pubkey)
|
|
||||||
|
|
||||||
if sc.data == vout[i].script_pubkey.data:
|
verified_derivation_path = self.verified_output_derivation_paths[i]
|
||||||
is_change = True
|
|
||||||
|
if rebuilt_script_pubkey.data == vout[i].script_pubkey.data:
|
||||||
|
# The scriptPubKey we created using our own seed matched what this
|
||||||
|
# output is actually committing to.
|
||||||
|
|
||||||
|
if singlesig_derivation_path is not None:
|
||||||
|
if verified_derivation_path is None:
|
||||||
|
# The output pays this seed but the psbt claimed a different
|
||||||
|
# fingerprint here. We treat this deception as an attack.
|
||||||
|
raise PSBTOutputOwnershipContradictionError(f"Output pays this seed at {bip32.path_to_str(singlesig_derivation_path)} but does not claim it there")
|
||||||
|
|
||||||
|
if verified_derivation_path != singlesig_derivation_path:
|
||||||
|
# Shouldn't be able to reach here: the surplus check above
|
||||||
|
# allows only one entry, and the ownership scan refuses a
|
||||||
|
# scope populating both derivation path maps, so the scan can
|
||||||
|
# only have verified this same path.
|
||||||
|
raise RuntimeError(f"Output {i} verified at a path it does not pay")
|
||||||
|
|
||||||
|
# We've now verified that the key we derived from our seed at the
|
||||||
|
# claimed path is the key this output pays. Despite the "presumed"
|
||||||
|
# variable name, the output IS provably ours.
|
||||||
|
is_presumed_change = True
|
||||||
|
|
||||||
|
elif multisig_script is not None:
|
||||||
|
if verified_derivation_path is None:
|
||||||
|
# No entry claimed this seed's fingerprint, but we already
|
||||||
|
# have everything we need to see if our seed is actually in
|
||||||
|
# the output script.
|
||||||
|
for derivation_path_obj in out.bip32_derivations.values():
|
||||||
|
# Each entry pairs a derivation path with the public key
|
||||||
|
# the coordinator says sits there. Both are its own
|
||||||
|
# claims, so we read only the path and derive the key
|
||||||
|
# ourselves.
|
||||||
|
seed_public_key = PSBTParser._derive_with_cache(self.root, derivation_path_obj.derivation, child_key_derivation_cache).get_public_key()
|
||||||
|
|
||||||
|
if PSBTParser._multisig_script_contains_key(multisig_script, seed_public_key):
|
||||||
|
# The output pays a multisig this seed is part
|
||||||
|
# of, but the psbt did not claim our key there.
|
||||||
|
# We treat this deception as an attack.
|
||||||
|
raise PSBTOutputOwnershipContradictionError(f"Output's committed script holds this seed's key at {bip32.path_to_str(derivation_path_obj.derivation)} but the psbt claims another fingerprint and/or public key there")
|
||||||
|
|
||||||
|
# We have derived a key from our seed for every derivation
|
||||||
|
# path this output supplies, but none of our keys match any
|
||||||
|
# of the keys in this output's script. So we consider this
|
||||||
|
# output an external spend.
|
||||||
|
pass
|
||||||
|
|
||||||
|
else:
|
||||||
|
# This output claimed that our seed is part of the receiving
|
||||||
|
# multisig, at a specific path. So now we verify that the key
|
||||||
|
# at that path is in the committed script.
|
||||||
|
seed_public_key = PSBTParser._derive_with_cache(self.root, verified_derivation_path, child_key_derivation_cache).get_public_key()
|
||||||
|
if not PSBTParser._multisig_script_contains_key(multisig_script, seed_public_key):
|
||||||
|
# The psbt said this output was coming back to our seed
|
||||||
|
# at that path, but the key there is not in the committed
|
||||||
|
# script. We treat this deception as an attack.
|
||||||
|
raise PSBTOutputOwnershipContradictionError(f"Output claims this seed at {bip32.path_to_str(verified_derivation_path)} but its committed script does not hold that key")
|
||||||
|
|
||||||
|
# The output should not describe more keys than are actually
|
||||||
|
# used in its script. We check for the more serious deceptions
|
||||||
|
# before this so they can be surfaced first.
|
||||||
|
if len(out.bip32_derivations) > self.policy["n"]:
|
||||||
|
# We don't try to decide if this is an attack or a
|
||||||
|
# mistake. We just abort the parse.
|
||||||
|
raise PSBTSurplusDerivationPathsError("Multisig output claims more derivation paths than its script has keys")
|
||||||
|
|
||||||
|
# We now know that our key is in the committed script; this
|
||||||
|
# output does pay to a multisig that our seed is part of. But
|
||||||
|
# note that we do not know yet if this is truly change coming
|
||||||
|
# back to our wallet or if it is paying out to a different
|
||||||
|
# multisig that happens to include our seed. Final change
|
||||||
|
# verification can only happen if and when the user loads
|
||||||
|
# their "known-good" multisig descriptor.
|
||||||
|
is_presumed_change = True
|
||||||
|
|
||||||
|
# One thing we can rule out now: if the psbt supplied global
|
||||||
|
# xpubs (see _get_cosigners), we can compare this output's
|
||||||
|
# cosigners to the inputs' cosigners. Real change should have
|
||||||
|
# the same cosigners; if this output's cosigners differ or
|
||||||
|
# fail to resolve at all, we classify this output as NOT
|
||||||
|
# change.
|
||||||
|
input_cosigners = self.policy.get("cosigners")
|
||||||
|
output_cosigners = out_policy.get("cosigners")
|
||||||
|
if input_cosigners is not None and input_cosigners != output_cosigners:
|
||||||
|
is_presumed_change = False
|
||||||
|
|
||||||
|
elif verified_derivation_path is not None and self.policy["type"] != "p2tr":
|
||||||
|
# The psbt claims one of this seed's keys on this output, yet the
|
||||||
|
# output does NOT pay what that claim describes. We treat this
|
||||||
|
# deception as an attack.
|
||||||
|
# * single sig: verified that this output is not paying our seed at
|
||||||
|
# the claimed derivation path.
|
||||||
|
# * multisig: verified that the output's claimed script is not the
|
||||||
|
# one the output commits to. Note that we haven't verified our
|
||||||
|
# seed's participation in the claimed script; it's irrelevant if
|
||||||
|
# that script isn't committed to in the scriptPubKey.
|
||||||
|
# Taproot is exempt: an output paying our internal key tweaked by
|
||||||
|
# a script tree fails the rebuild above even when the psbt claimed
|
||||||
|
# the seed truthfully, and from here that is indistinguishable
|
||||||
|
# from an output that claims our key and pays someone else.
|
||||||
|
# TODO: Parse PSBT_OUT_TAP_TREE, which embit leaves unparsed in
|
||||||
|
# the scope's `unknown` map. Its merkle root is what separates the
|
||||||
|
# two: a tree that tweaks our key to the committed key makes the
|
||||||
|
# output verifiable change, one that does not is a contradiction to
|
||||||
|
# refuse here, and an output supplying no tree stays exempt, since
|
||||||
|
# an omitted optional field is not a contradiction.
|
||||||
|
raise PSBTOutputOwnershipContradictionError(f"Output claims this seed at {bip32.path_to_str(verified_derivation_path)} but its committed script contradicts that")
|
||||||
|
|
||||||
if vout[i].script_pubkey.data[0] == OPCODES.OP_RETURN:
|
if vout[i].script_pubkey.data[0] == OPCODES.OP_RETURN:
|
||||||
# The data is written as: OP_RETURN + OP_PUSHDATA1 + len(payload) + payload
|
# The data is written as: OP_RETURN + OP_PUSHDATA1 + len(payload) + payload
|
||||||
self.op_return_data = vout[i].script_pubkey.data[3:]
|
self.op_return_data = vout[i].script_pubkey.data[3:]
|
||||||
|
|
||||||
elif is_change:
|
elif is_presumed_change:
|
||||||
|
# Remember that "change" in this function is ANY output coming back to our
|
||||||
|
# seed, receive addresses included. It is up to the View layer to use the
|
||||||
|
# derivation path to determine if it should be displayed as change or
|
||||||
|
# receive.
|
||||||
addr = vout[i].script_pubkey.address(NETWORKS[SettingsConstants.map_network_to_embit(self.network)])
|
addr = vout[i].script_pubkey.address(NETWORKS[SettingsConstants.map_network_to_embit(self.network)])
|
||||||
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():
|
|
||||||
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():
|
|
||||||
claimed_fingerprints.append(hexlify(derivation.fingerprint).decode())
|
|
||||||
claimed_derivation_paths.append(bip32.path_to_str(derivation.derivation))
|
|
||||||
|
|
||||||
self.change_data.append({
|
self.change_data.append({
|
||||||
"output_index": i,
|
"output_index": i,
|
||||||
"address": addr,
|
"address": addr,
|
||||||
"amount": vout[i].value,
|
"amount": vout[i].value,
|
||||||
"claimed_fingerprints": claimed_fingerprints,
|
"verified_derivation_path": self.verified_output_derivation_paths[i],
|
||||||
"claimed_derivation_paths": claimed_derivation_paths,
|
|
||||||
})
|
})
|
||||||
self.change_amount += vout[i].value
|
self.change_amount += vout[i].value
|
||||||
|
|
||||||
@@ -436,7 +678,7 @@ class PSBTParser():
|
|||||||
|
|
||||||
if script is not None:
|
if script is not None:
|
||||||
m, n, pubkeys = PSBTParser._parse_multisig(script)
|
m, n, pubkeys = PSBTParser._parse_multisig(script)
|
||||||
|
|
||||||
# check pubkeys are derived from cosigners
|
# check pubkeys are derived from cosigners
|
||||||
try:
|
try:
|
||||||
cosigners = PSBTParser._get_cosigners(pubkeys, scope.bip32_derivations, xpubs, child_key_derivation_cache)
|
cosigners = PSBTParser._get_cosigners(pubkeys, scope.bip32_derivations, xpubs, child_key_derivation_cache)
|
||||||
@@ -445,14 +687,55 @@ class PSBTParser():
|
|||||||
# TODO: stop swallowing everything here. This also catches bugs in the
|
# TODO: stop swallowing everything here. This also catches bugs in the
|
||||||
# cosigner check itself, and cannot tell those apart from the psbt
|
# cosigner check itself, and cannot tell those apart from the psbt
|
||||||
# simply not supplying xpubs to check against, which is valid and must
|
# simply not supplying xpubs to check against, which is valid and must
|
||||||
# not be rejected outright. The fallback policy carries no cosigner
|
# not be rejected outright.
|
||||||
# information at all, and two of those compare equal on script type
|
|
||||||
# and m-of-n alone. Fix pending with the multisig verification work.
|
|
||||||
policy.update({"m": m, "n": n})
|
policy.update({"m": m, "n": n})
|
||||||
|
|
||||||
return policy
|
return policy
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _policy_shape_matches(policy_a: dict, policy_b: dict) -> 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.
|
||||||
|
"""
|
||||||
|
for field in ("type", "m", "n"):
|
||||||
|
if policy_a.get(field) != policy_b.get(field):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_singlesig_script(policy_type: str, public_key: PublicKey) -> script.Script:
|
||||||
|
"""
|
||||||
|
Builds the scriptPubKey that pays public_key under the given single-sig
|
||||||
|
policy_type.
|
||||||
|
"""
|
||||||
|
if policy_type == "p2pkh":
|
||||||
|
return script.p2pkh(public_key)
|
||||||
|
|
||||||
|
if policy_type == "p2sh-p2wpkh":
|
||||||
|
return script.p2sh(script.p2wpkh(public_key))
|
||||||
|
|
||||||
|
if policy_type == "p2wpkh":
|
||||||
|
return script.p2wpkh(public_key)
|
||||||
|
|
||||||
|
if policy_type == "p2tr":
|
||||||
|
return script.p2tr(public_key)
|
||||||
|
|
||||||
|
# Shouldn't be able to reach here. Just a guard against a future developer calling
|
||||||
|
# this with invalid args.
|
||||||
|
raise RuntimeError(f"Not a single-sig script type: {policy_type}")
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_multisig(multisig_script):
|
def _parse_multisig(multisig_script):
|
||||||
"""Takes a script and extracts m,n and pubkeys from it"""
|
"""Takes a script and extracts m,n and pubkeys from it"""
|
||||||
@@ -482,6 +765,15 @@ class PSBTParser():
|
|||||||
return m, n, pubkeys
|
return m, n, pubkeys
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _multisig_script_contains_key(multisig_script: script.Script, public_key: PublicKey) -> bool:
|
||||||
|
"""
|
||||||
|
Determines whether multisig_script includes the provided public_key.
|
||||||
|
"""
|
||||||
|
m, n, pubkeys = PSBTParser._parse_multisig(multisig_script)
|
||||||
|
return any(pubkey.sec() == public_key.sec() for pubkey in pubkeys)
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _derive_with_cache(parent_key: bip32.HDKey, derivation_path: List[int], child_key_derivation_cache: dict | None = None) -> bip32.HDKey:
|
def _derive_with_cache(parent_key: bip32.HDKey, derivation_path: List[int], child_key_derivation_cache: dict | None = None) -> bip32.HDKey:
|
||||||
"""
|
"""
|
||||||
@@ -535,25 +827,72 @@ class PSBTParser():
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_cosigners(pubkeys, derivations, xpubs, child_key_derivation_cache: dict | None):
|
def _get_cosigners(pubkeys, derivations, xpubs, child_key_derivation_cache: dict | None):
|
||||||
"""Returns xpubs used to derive pubkeys using global xpub field from psbt"""
|
"""
|
||||||
|
Traces every key in a multisig script back to the global xpub it was derived
|
||||||
|
from, then returns the xpubs it found as a sorted list of base58 strings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
* pubkeys: The keys that actually appear in the script (the witness script for
|
||||||
|
segwit; the redeem script for legacy p2sh). Extracted by _get_policy(). One
|
||||||
|
per cosigner.
|
||||||
|
|
||||||
|
* derivations: (embit's bip32_derivations) Each pubkey's associated fingerprint
|
||||||
|
and full derivation path (e.g. m/48'/0'/0'/2'/1/5). A dict keyed on each
|
||||||
|
pubkey.
|
||||||
|
|
||||||
|
* xpubs: aka "global xpubs". The account-level xpub, with its associated
|
||||||
|
fingerprint and derivation path, but only down to the account level (e.g.
|
||||||
|
m/48'/0'/0'/2'). A dict keyed on each xpub.
|
||||||
|
|
||||||
|
The derivations and xpubs are unproven claims provided by the coordinator. So we
|
||||||
|
take each pubkey's claimed derivation path and check whether one of the xpubs
|
||||||
|
really derives that pubkey.
|
||||||
|
|
||||||
|
The resulting cosigners list consists of each xpub that provably derives each of
|
||||||
|
the script's keys. But that is ALL it proves. We have no way to verify who those
|
||||||
|
xpubs actually belong to; the coordinator can list any xpubs it likes.
|
||||||
|
|
||||||
|
The list is sorted so that two scripts holding the same wallet's keys in a
|
||||||
|
different order resolve to the same cosigners.
|
||||||
|
|
||||||
|
Note that the bip32_derivations and the global xpubs are both optional psbt
|
||||||
|
fields. If either is omitted or incomplete, this function raises rather than
|
||||||
|
return a partial list.
|
||||||
|
"""
|
||||||
|
# TODO: Improve error handling by providing custom exceptions.
|
||||||
|
|
||||||
|
# Early-out if the optional data is omitted. Not actually an error: raising is
|
||||||
|
# how this function reports that a complete cosigner list can't be built.
|
||||||
|
if not xpubs:
|
||||||
|
raise ValueError("No global xpubs supplied")
|
||||||
|
if not derivations:
|
||||||
|
raise ValueError("No derivation paths supplied")
|
||||||
|
|
||||||
cosigners = []
|
cosigners = []
|
||||||
for i, pubkey in enumerate(pubkeys):
|
for i, pubkey in enumerate(pubkeys):
|
||||||
|
# For each pubkey, get the claimed fingerprint and full derivation path
|
||||||
if pubkey not in derivations:
|
if pubkey not in derivations:
|
||||||
raise ValueError("Missing derivation")
|
raise ValueError("Missing derivation")
|
||||||
der = derivations[pubkey]
|
der = derivations[pubkey]
|
||||||
|
|
||||||
|
# Scan the xpubs for one whose derivation path matches the claim.
|
||||||
for xpub in xpubs:
|
for xpub in xpubs:
|
||||||
origin_der = xpubs[xpub]
|
origin_der = xpubs[xpub]
|
||||||
# check fingerprint
|
# The full derivation path goes two indices deeper than the xpub's so we
|
||||||
if origin_der.fingerprint == der.fingerprint:
|
# omit those last two when comparing.
|
||||||
# check derivation - last two indexes give pub from xpub
|
if origin_der.derivation == der.derivation[:-2]:
|
||||||
if origin_der.derivation == der.derivation[:-2]:
|
# Derive the child key that sits two indices below the xpub (i.e. at
|
||||||
# check that it derives to pubkey actually
|
# the full derivation path).
|
||||||
derived_key = PSBTParser._derive_with_cache(
|
derived_key = PSBTParser._derive_with_cache(xpub, der.derivation[-2:], child_key_derivation_cache)
|
||||||
xpub, der.derivation[-2:], child_key_derivation_cache)
|
|
||||||
if derived_key.key == pubkey:
|
# Finally, compare that key with the target pubkey
|
||||||
# append strings so they can be sorted and compared
|
if derived_key.key == pubkey:
|
||||||
cosigners.append(xpub.to_base58())
|
# Append as strings so they can be sorted and compared
|
||||||
break
|
cosigners.append(xpub.to_base58())
|
||||||
|
break
|
||||||
|
|
||||||
|
# Every key in the script has to trace back to an xpub for the result to mean
|
||||||
|
# anything.
|
||||||
if len(cosigners) != len(pubkeys):
|
if len(cosigners) != len(pubkeys):
|
||||||
raise RuntimeError("Can't get all cosigners")
|
raise RuntimeError("Can't get all cosigners")
|
||||||
return sorted(cosigners)
|
return sorted(cosigners)
|
||||||
@@ -576,7 +915,7 @@ class PSBTParser():
|
|||||||
for pub, (leaf_hashes, derivation_path) in input.taproot_bip32_derivations.items():
|
for pub, (leaf_hashes, derivation_path) in input.taproot_bip32_derivations.items():
|
||||||
# TODO: Support spends from leaves; depends on support in embit
|
# TODO: Support spends from leaves; depends on support in embit
|
||||||
if len(leaf_hashes) > 0:
|
if len(leaf_hashes) > 0:
|
||||||
raise Exception("Signing keyspends from within a taptree not yet implemented")
|
raise Exception("Signing script path spends is not yet implemented")
|
||||||
fingerprints.add(hexlify(derivation_path.fingerprint).decode())
|
fingerprints.add(hexlify(derivation_path.fingerprint).decode())
|
||||||
return list(fingerprints)
|
return list(fingerprints)
|
||||||
|
|
||||||
@@ -593,7 +932,7 @@ class PSBTParser():
|
|||||||
instance.
|
instance.
|
||||||
"""
|
"""
|
||||||
seed_fingerprint = seed.get_fingerprint(network)
|
seed_fingerprint = seed.get_fingerprint(network)
|
||||||
|
|
||||||
def check_fingerprint_match(public_key: PublicKey, derivation_path_obj: DerivationPath, is_taproot: bool):
|
def check_fingerprint_match(public_key: PublicKey, derivation_path_obj: DerivationPath, is_taproot: bool):
|
||||||
"""Check fingerprint match with missing fingerprint fallback"""
|
"""Check fingerprint match with missing fingerprint fallback"""
|
||||||
|
|
||||||
@@ -621,7 +960,7 @@ class PSBTParser():
|
|||||||
for public_key, (leaf_hashes, derivation_path_obj) in input.taproot_bip32_derivations.items():
|
for public_key, (leaf_hashes, derivation_path_obj) in input.taproot_bip32_derivations.items():
|
||||||
if check_fingerprint_match(public_key, derivation_path_obj, is_taproot=True):
|
if check_fingerprint_match(public_key, derivation_path_obj, is_taproot=True):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -667,6 +1006,15 @@ class PSBTParser():
|
|||||||
Every key in the scope that claims this seed's fingerprint is re-derived and
|
Every key in the scope that claims this seed's fingerprint is re-derived and
|
||||||
checked. A false claim raises PSBT[Output|Input]OwnershipClaimError.
|
checked. A false claim raises PSBT[Output|Input]OwnershipClaimError.
|
||||||
|
|
||||||
|
A further check is then enforced: a scope carrying entries in the
|
||||||
|
bip32_derivations AND taproot_bip32_derivations maps raises
|
||||||
|
PSBTMixedDerivationPathTypesError. Taproot keys are exclusively x-only while
|
||||||
|
non-taproot keys always carry their parity byte; there is no script type that can
|
||||||
|
make use of both types of keys so it is nonsensical for a scope to provide both.
|
||||||
|
|
||||||
|
Note that neither BIP-174 nor BIP-371 forbids the combination. And embit will
|
||||||
|
parse and even sign such a psbt. We disallow it by opinionated choice.
|
||||||
|
|
||||||
One edge case:
|
One edge case:
|
||||||
* A multisig could use this seed in more than one cosigner slot, each
|
* A multisig could use this seed in more than one cosigner slot, each
|
||||||
at its own derivation path. The scope then carries several entries that all
|
at its own derivation path. The scope then carries several entries that all
|
||||||
@@ -700,17 +1048,23 @@ class PSBTParser():
|
|||||||
_check_claim(public_key, derivation_path_obj, is_taproot=False)
|
_check_claim(public_key, derivation_path_obj, is_taproot=False)
|
||||||
|
|
||||||
for public_key, (leaf_hashes, derivation_path_obj) in scope.taproot_bip32_derivations.items():
|
for public_key, (leaf_hashes, derivation_path_obj) in scope.taproot_bip32_derivations.items():
|
||||||
# TODO: Support keys in taptree leaves
|
# TODO: Support keys in script tree leaves
|
||||||
_check_claim(public_key, derivation_path_obj, is_taproot=True)
|
_check_claim(public_key, derivation_path_obj, is_taproot=True)
|
||||||
|
|
||||||
|
# The derivation path maps cannot both be populated in the same scope. Checked
|
||||||
|
# after the loops so that a false ownership claim, the more serious finding, is
|
||||||
|
# still the one reported.
|
||||||
|
if scope.bip32_derivations and scope.taproot_bip32_derivations:
|
||||||
|
raise PSBTMixedDerivationPathTypesError("Scope declares both ecdsa and taproot derivation paths")
|
||||||
|
|
||||||
return verified_derivation_path
|
return verified_derivation_path
|
||||||
|
|
||||||
|
|
||||||
def _verify_claimed_derivation_paths(self, child_key_derivation_cache: dict):
|
def _verify_claimed_derivation_paths(self, child_key_derivation_cache: dict):
|
||||||
"""
|
"""
|
||||||
Verifies every claimed derivation path that names this seed's fingerprint. The
|
Verifies every derivation path entry that claims this seed's fingerprint. The
|
||||||
result, stored in verified_[input|output]_derivation_paths, is either the verified
|
result, stored in verified_[input|output]_derivation_paths, is either the verified
|
||||||
derivation path or None (the seed was not named) for each input/output scope.
|
derivation path or None (no entry claimed this seed) for each input/output scope.
|
||||||
|
|
||||||
The coordinator-supplied fingerprints cannot be trusted as-is. We must derive and
|
The coordinator-supplied fingerprints cannot be trusted as-is. We must derive and
|
||||||
verify the ownership of each one that claims to belong to this seed.
|
verify the ownership of each one that claims to belong to this seed.
|
||||||
@@ -749,7 +1103,7 @@ class PSBTParser():
|
|||||||
harmless: the excluded key cannot spend the input, so nothing of this seed's is
|
harmless: the excluded key cannot spend the input, so nothing of this seed's is
|
||||||
at risk.)
|
at risk.)
|
||||||
"""
|
"""
|
||||||
# An input names a key at a derivation path and _verify_claimed_derivation_paths
|
# An input claims a key at a derivation path and _verify_claimed_derivation_paths
|
||||||
# proved the seed derives it (single-sig: one such key; multisig: one per
|
# proved the seed derives it (single-sig: one such key; multisig: one per
|
||||||
# cosigner, ours among them). One verified input path is enough for the psbt to
|
# cosigner, ours among them). One verified input path is enough for the psbt to
|
||||||
# be signable.
|
# be signable.
|
||||||
@@ -760,6 +1114,15 @@ class PSBTParser():
|
|||||||
raise PSBTSeedCannotSignError()
|
raise PSBTSeedCannotSignError()
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_change_branch(derivation_path: List[int]) -> bool:
|
||||||
|
"""
|
||||||
|
Returns True if the next-to-last element of the derivation path is the change
|
||||||
|
branch (1).
|
||||||
|
"""
|
||||||
|
return len(derivation_path) >= 2 and derivation_path[-2] == 1
|
||||||
|
|
||||||
|
|
||||||
def verify_multisig_output(self, descriptor: Descriptor, change_num: int) -> bool:
|
def verify_multisig_output(self, descriptor: Descriptor, change_num: int) -> bool:
|
||||||
change_data = self.get_change_data(change_num)
|
change_data = self.get_change_data(change_num)
|
||||||
i = change_data["output_index"]
|
i = change_data["output_index"]
|
||||||
|
|||||||
+169
-100
@@ -1,11 +1,13 @@
|
|||||||
from gettext import gettext as _
|
from gettext import gettext as _
|
||||||
|
|
||||||
from seedsigner.models.psbt_parser import (PSBTInputOwnershipClaimError,
|
from seedsigner.models.psbt_parser import (PSBTInputOwnershipClaimError,
|
||||||
PSBTOutputOwnershipClaimError, PSBTParser, PSBTSeedCannotSignError)
|
PSBTMixedDerivationPathTypesError, PSBTOutputOwnershipClaimError,
|
||||||
|
PSBTOutputOwnershipContradictionError, PSBTParser, PSBTSeedCannotSignError,
|
||||||
|
PSBTSurplusDerivationPathsError)
|
||||||
from seedsigner.models.settings import SettingsConstants
|
from seedsigner.models.settings import SettingsConstants
|
||||||
from seedsigner.gui.components import FontAwesomeIconConstants, GUIConstants, SeedSignerIconConstants
|
from seedsigner.gui.components import FontAwesomeIconConstants, GUIConstants, SeedSignerIconConstants
|
||||||
from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, ButtonOption, LargeIconStatusScreen, WarningScreen, DireWarningScreen, QRDisplayScreen)
|
from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, ButtonOption, LargeIconStatusScreen, WarningScreen, DireWarningScreen, QRDisplayScreen)
|
||||||
from seedsigner.views.view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination
|
from seedsigner.views.view import BackStackView, MainMenuView, View, Destination
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -103,16 +105,28 @@ class PSBTOverviewView(View):
|
|||||||
network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)
|
network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Note that in almost every exception case, we set clear_history to disable
|
||||||
|
# returning via BACK button in the Destination.
|
||||||
except PSBTInputOwnershipClaimError:
|
except PSBTInputOwnershipClaimError:
|
||||||
# Set clear_history to disable returning via BACK button
|
|
||||||
self.set_redirect(Destination(PSBTInputOwnershipClaimFailedView, clear_history=True))
|
self.set_redirect(Destination(PSBTInputOwnershipClaimFailedView, clear_history=True))
|
||||||
return
|
return
|
||||||
|
|
||||||
except PSBTOutputOwnershipClaimError:
|
except PSBTOutputOwnershipClaimError:
|
||||||
# Set clear_history to disable returning via BACK button
|
|
||||||
self.set_redirect(Destination(PSBTOutputOwnershipClaimFailedView, clear_history=True))
|
self.set_redirect(Destination(PSBTOutputOwnershipClaimFailedView, clear_history=True))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
except PSBTSurplusDerivationPathsError:
|
||||||
|
self.set_redirect(Destination(PSBTSurplusDerivationPathsView, clear_history=True))
|
||||||
|
return
|
||||||
|
|
||||||
|
except PSBTMixedDerivationPathTypesError:
|
||||||
|
self.set_redirect(Destination(PSBTMixedDerivationPathTypesView, clear_history=True))
|
||||||
|
return
|
||||||
|
|
||||||
|
except PSBTOutputOwnershipContradictionError:
|
||||||
|
self.set_redirect(Destination(PSBTOutputOwnershipContradictionView, clear_history=True))
|
||||||
|
return
|
||||||
|
|
||||||
except PSBTSeedCannotSignError:
|
except PSBTSeedCannotSignError:
|
||||||
# Not a suspicious psbt, just the wrong seed for it. Send the user back to
|
# Not a suspicious psbt, just the wrong seed for it. Send the user back to
|
||||||
# pick another rather than clearing the flow.
|
# pick another rather than clearing the flow.
|
||||||
@@ -133,17 +147,18 @@ class PSBTOverviewView(View):
|
|||||||
"""
|
"""
|
||||||
change_data = [
|
change_data = [
|
||||||
{
|
{
|
||||||
|
'output_index': 0,
|
||||||
'address': 'bc1q............',
|
'address': 'bc1q............',
|
||||||
'amount': 397621401,
|
'amount': 397621401,
|
||||||
'claimed_fingerprints': ['22bde1a9', '73c5da0a'],
|
'verified_derivation_path':
|
||||||
'claimed_derivation_paths': ['m/48h/1h/0h/2h/1/0', 'm/48h/1h/0h/2h/1/0']
|
[2147483696, 2147483649, 2147483648, 2147483650, 1, 0],
|
||||||
}, {},
|
}, {},
|
||||||
]
|
]
|
||||||
"""
|
"""
|
||||||
num_change_outputs = 0
|
num_change_outputs = 0
|
||||||
num_self_transfer_outputs = 0
|
num_self_transfer_outputs = 0
|
||||||
for change_output in change_data:
|
for change_output in change_data:
|
||||||
if change_output["claimed_derivation_paths"][0].split("/")[-2] == "1":
|
if PSBTParser.is_change_branch(change_output["verified_derivation_path"]):
|
||||||
num_change_outputs += 1
|
num_change_outputs += 1
|
||||||
else:
|
else:
|
||||||
num_self_transfer_outputs += 1
|
num_self_transfer_outputs += 1
|
||||||
@@ -325,6 +340,7 @@ class PSBTChangeDetailsView(View):
|
|||||||
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
|
from embit import bip32
|
||||||
from seedsigner.gui.screens.psbt_screens import PSBTChangeDetailsScreen
|
from seedsigner.gui.screens.psbt_screens import PSBTChangeDetailsScreen
|
||||||
psbt_parser: PSBTParser = self.controller.psbt_parser
|
psbt_parser: PSBTParser = self.controller.psbt_parser
|
||||||
|
|
||||||
@@ -332,32 +348,24 @@ class PSBTChangeDetailsView(View):
|
|||||||
# Should not be able to get here
|
# Should not be able to get here
|
||||||
return Destination(MainMenuView)
|
return Destination(MainMenuView)
|
||||||
|
|
||||||
# Can we verify this change addr?
|
|
||||||
change_data = psbt_parser.get_change_data(change_num=self.change_address_num)
|
change_data = psbt_parser.get_change_data(change_num=self.change_address_num)
|
||||||
"""
|
"""
|
||||||
change_data:
|
change_data:
|
||||||
{
|
{
|
||||||
|
'output_index': 0,
|
||||||
'address': 'bc1q............',
|
'address': 'bc1q............',
|
||||||
'amount': 397621401,
|
'amount': 397621401,
|
||||||
'claimed_fingerprints': ['22bde1a9', '73c5da0a'],
|
'verified_derivation_path':
|
||||||
'claimed_derivation_paths': ['m/48h/1h/0h/2h/1/0', 'm/48h/1h/0h/2h/1/0']
|
[2147483696, 2147483649, 2147483648, 2147483650, 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))
|
seed_fingerprint = self.controller.psbt_seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK))
|
||||||
|
verified_derivation_path = change_data.get("verified_derivation_path")
|
||||||
|
is_change_derivation_path = PSBTParser.is_change_branch(verified_derivation_path)
|
||||||
|
|
||||||
if seed_fingerprint not in change_data.get("claimed_fingerprints"):
|
# TODO: Refuse a path too short to carry a branch and an address index; until then
|
||||||
# TODO: Something is wrong with this psbt(?). Reroute to warning?
|
# this can raise on a malformed psbt.
|
||||||
return Destination(NotYetImplementedView)
|
derivation_path_addr_index = verified_derivation_path[-1]
|
||||||
|
|
||||||
i = change_data.get("claimed_fingerprints").index(seed_fingerprint)
|
|
||||||
claimed_derivation_path = change_data.get("claimed_derivation_paths")[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(claimed_derivation_path.split("/")[-2]) == 1
|
|
||||||
derivation_path_addr_index = int(claimed_derivation_path.split("/")[-1])
|
|
||||||
|
|
||||||
if is_change_derivation_path:
|
if is_change_derivation_path:
|
||||||
# TRANSLATOR_NOTE: The amount you're receiving back from the transaction
|
# TRANSLATOR_NOTE: The amount you're receiving back from the transaction
|
||||||
@@ -370,66 +378,33 @@ class PSBTChangeDetailsView(View):
|
|||||||
|
|
||||||
is_change_addr_verified = False
|
is_change_addr_verified = False
|
||||||
if psbt_parser.is_multisig:
|
if psbt_parser.is_multisig:
|
||||||
|
# Multisig is verified here rather than during the initial parse because the
|
||||||
|
# descriptor it needs to be checked against does not arrive until mid-flow.
|
||||||
|
# TODO: Verify the multisig change as soon as the descriptor is loaded, rather
|
||||||
|
# than waiting to do it here.
|
||||||
|
|
||||||
# if the known-good multisig descriptor is already onboard:
|
# if the known-good multisig descriptor is already onboard:
|
||||||
if self.controller.multisig_wallet_descriptor:
|
if self.controller.multisig_wallet_descriptor:
|
||||||
is_change_addr_verified = psbt_parser.verify_multisig_output(self.controller.multisig_wallet_descriptor, change_num=self.change_address_num)
|
is_change_addr_verified = psbt_parser.verify_multisig_output(self.controller.multisig_wallet_descriptor, change_num=self.change_address_num)
|
||||||
button_data = [self.NEXT]
|
button_data = [self.NEXT]
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Have the Screen offer to load in the multisig descriptor.
|
# Nothing to check against yet. Have the Screen offer to load in the
|
||||||
|
# multisig descriptor.
|
||||||
button_data = [self.VERIFY_MULTISIG, self.SKIP_VERIFICATION]
|
button_data = [self.VERIFY_MULTISIG, self.SKIP_VERIFICATION]
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Single sig
|
# The PSBTParser already proves that single sig change outputs are owned by
|
||||||
try:
|
# this seed.
|
||||||
from embit import script
|
is_change_addr_verified = True
|
||||||
from embit.networks import NETWORKS
|
button_data = [self.NEXT]
|
||||||
|
|
||||||
if is_change_derivation_path:
|
# TODO: Will be unnecessary once the above update is made to verify multisig
|
||||||
loading_screen_text = _("Verifying Change...")
|
# change as soon as the descriptor is loaded.
|
||||||
else:
|
if not is_change_addr_verified and self.controller.multisig_wallet_descriptor is not None:
|
||||||
loading_screen_text = _("Verifying Self-Transfer...")
|
# Verification failed, so this psbt is done.
|
||||||
from seedsigner.gui.screens.screen import LoadingScreenThread
|
# Set clear_history to disable returning via BACK button.
|
||||||
loading_screen = LoadingScreenThread(text=loading_screen_text)
|
return Destination(PSBTAddressVerificationFailedView, view_args=dict(is_change=is_change_derivation_path), clear_history=True)
|
||||||
loading_screen.start()
|
|
||||||
|
|
||||||
# convert change address to script pubkey to get script type
|
|
||||||
pubkey = script.address_to_scriptpubkey(change_data["address"])
|
|
||||||
script_type = pubkey.script_type()
|
|
||||||
|
|
||||||
# extract derivation path to get wallet and change derivation
|
|
||||||
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,
|
|
||||||
network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)
|
|
||||||
)
|
|
||||||
|
|
||||||
# take script type and call script method to generate address from seed / derivation
|
|
||||||
xpub_key = xpub.derive(change_path).key
|
|
||||||
network = self.settings.get_value(SettingsConstants.SETTING__NETWORK)
|
|
||||||
scriptcall = getattr(script, script_type)
|
|
||||||
if script_type == "p2sh":
|
|
||||||
# single sig only so p2sh is always p2sh-p2wpkh
|
|
||||||
calc_address = script.p2sh(script.p2wpkh(xpub_key)).address(
|
|
||||||
network=NETWORKS[SettingsConstants.map_network_to_embit(network)]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# single sig so this handles p2wpkh and p2wpkh (and p2tr in the future)
|
|
||||||
calc_address = scriptcall(xpub_key).address(
|
|
||||||
network=NETWORKS[SettingsConstants.map_network_to_embit(network)]
|
|
||||||
)
|
|
||||||
|
|
||||||
if change_data["address"] == calc_address:
|
|
||||||
is_change_addr_verified = True
|
|
||||||
button_data = [self.NEXT]
|
|
||||||
|
|
||||||
finally:
|
|
||||||
loading_screen.stop()
|
|
||||||
|
|
||||||
if is_change_addr_verified == False and (not psbt_parser.is_multisig or self.controller.multisig_wallet_descriptor is not None):
|
|
||||||
return Destination(PSBTAddressVerificationFailedView, view_args=dict(is_change=is_change_derivation_path, is_multisig=psbt_parser.is_multisig), clear_history=True)
|
|
||||||
|
|
||||||
selected_menu_num = self.run_screen(
|
selected_menu_num = self.run_screen(
|
||||||
PSBTChangeDetailsScreen,
|
PSBTChangeDetailsScreen,
|
||||||
@@ -439,7 +414,7 @@ class PSBTChangeDetailsView(View):
|
|||||||
amount=change_data.get("amount"),
|
amount=change_data.get("amount"),
|
||||||
is_multisig=psbt_parser.is_multisig,
|
is_multisig=psbt_parser.is_multisig,
|
||||||
fingerprint=seed_fingerprint,
|
fingerprint=seed_fingerprint,
|
||||||
derivation_path=claimed_derivation_path,
|
derivation_path=bip32.path_to_str(verified_derivation_path),
|
||||||
is_change_derivation_path=is_change_derivation_path,
|
is_change_derivation_path=is_change_derivation_path,
|
||||||
derivation_path_addr_index=derivation_path_addr_index,
|
derivation_path_addr_index=derivation_path_addr_index,
|
||||||
is_change_addr_verified=is_change_addr_verified,
|
is_change_addr_verified=is_change_addr_verified,
|
||||||
@@ -470,7 +445,10 @@ class PSBTChangeDetailsView(View):
|
|||||||
class PSBTSeedCannotSignView(View):
|
class PSBTSeedCannotSignView(View):
|
||||||
"""
|
"""
|
||||||
Reached when parsing found this seed can't sign any of the psbt's inputs (see
|
Reached when parsing found this seed can't sign any of the psbt's inputs (see
|
||||||
PSBTSeedCannotSignError). Routes back to seed selection so the user can pick another.
|
PSBTSeedCannotSignError).
|
||||||
|
|
||||||
|
We do not view this as an attack; most likely the user simply selected the wrong seed.
|
||||||
|
Routes back to seed selection rather than discarding the psbt.
|
||||||
"""
|
"""
|
||||||
SELECT_DIFFERENT_SEED = ButtonOption("Select a different seed")
|
SELECT_DIFFERENT_SEED = ButtonOption("Select a different seed")
|
||||||
|
|
||||||
@@ -497,8 +475,11 @@ class PSBTSeedCannotSignView(View):
|
|||||||
class PSBTOutputOwnershipClaimFailedView(View):
|
class PSBTOutputOwnershipClaimFailedView(View):
|
||||||
"""
|
"""
|
||||||
Reached when a false ownership claim on an output rejects the psbt (see
|
Reached when a false ownership claim on an output rejects the psbt (see
|
||||||
PSBTOutputOwnershipClaimError). Shows a dire warning and discards the psbt to the main
|
PSBTOutputOwnershipClaimError). Claims on inputs route to
|
||||||
menu. Claims on inputs route to PSBTInputOwnershipClaimFailedView instead.
|
PSBTInputOwnershipClaimFailedView instead.
|
||||||
|
|
||||||
|
We view this as an attack. We do not allow the user to continue and give this the
|
||||||
|
"Dire Warning" level.
|
||||||
"""
|
"""
|
||||||
DISCARD = ButtonOption("Discard transaction")
|
DISCARD = ButtonOption("Discard transaction")
|
||||||
|
|
||||||
@@ -507,13 +488,13 @@ class PSBTOutputOwnershipClaimFailedView(View):
|
|||||||
DireWarningScreen,
|
DireWarningScreen,
|
||||||
title=_("Suspicious Transaction"),
|
title=_("Suspicious Transaction"),
|
||||||
status_headline=_("Likely an Attack!"),
|
status_headline=_("Likely an Attack!"),
|
||||||
text=_("The transaction's change/self-transfer outputs are not going back to your wallet."),
|
text=_("The transaction's change/self-transfer output is not going back to your wallet."),
|
||||||
button_data=[self.DISCARD],
|
button_data=[self.DISCARD],
|
||||||
show_back_button=False,
|
show_back_button=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# We're done with this PSBT. Route back to MainMenuView, which clears all ephemeral
|
# We're done with this PSBT. Route back to MainMenuView, which clears all
|
||||||
# data (except in-memory seeds).
|
# ephemeral data (except in-memory seeds).
|
||||||
# Set clear_history to disable returning via BACK button.
|
# Set clear_history to disable returning via BACK button.
|
||||||
return Destination(MainMenuView, clear_history=True)
|
return Destination(MainMenuView, clear_history=True)
|
||||||
|
|
||||||
@@ -522,8 +503,11 @@ class PSBTOutputOwnershipClaimFailedView(View):
|
|||||||
class PSBTInputOwnershipClaimFailedView(View):
|
class PSBTInputOwnershipClaimFailedView(View):
|
||||||
"""
|
"""
|
||||||
Reached when a false ownership claim on an input rejects the psbt (see
|
Reached when a false ownership claim on an input rejects the psbt (see
|
||||||
PSBTInputOwnershipClaimError). Shows a plain (not dire) warning and discards the psbt
|
PSBTInputOwnershipClaimError).
|
||||||
to the main menu.
|
|
||||||
|
We do not view this as an attack; a forged input claim only renders the psbt
|
||||||
|
unsignable. We do not allow the user to continue, but only give this the "Warning"
|
||||||
|
level.
|
||||||
"""
|
"""
|
||||||
DISCARD = ButtonOption("Discard transaction")
|
DISCARD = ButtonOption("Discard transaction")
|
||||||
|
|
||||||
@@ -532,13 +516,99 @@ class PSBTInputOwnershipClaimFailedView(View):
|
|||||||
WarningScreen,
|
WarningScreen,
|
||||||
title=_("Transaction Problem"),
|
title=_("Transaction Problem"),
|
||||||
status_headline=None,
|
status_headline=None,
|
||||||
text=_("This transaction incorrectly claims that its input(s) belong to this seed."),
|
text=_("This transaction incorrectly claims that one of its inputs belongs to this seed."),
|
||||||
button_data=[self.DISCARD],
|
button_data=[self.DISCARD],
|
||||||
show_back_button=False,
|
show_back_button=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# We're done with this PSBT. Route back to MainMenuView, which clears all ephemeral
|
# We're done with this PSBT. Route back to MainMenuView, which clears all
|
||||||
# data (except in-memory seeds).
|
# ephemeral data (except in-memory seeds).
|
||||||
|
# Set clear_history to disable returning via BACK button.
|
||||||
|
return Destination(MainMenuView, clear_history=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class PSBTSurplusDerivationPathsView(View):
|
||||||
|
"""
|
||||||
|
Reached when an output claims more derivation path entries than its script can use
|
||||||
|
(see PSBTSurplusDerivationPathsError).
|
||||||
|
|
||||||
|
The single sig case is a structural error. The multisig case could be an attempt to
|
||||||
|
deceive but since we don't know for sure, it's sufficient to just use the "Warning"
|
||||||
|
level and stop the user from continuing.
|
||||||
|
"""
|
||||||
|
DISCARD = ButtonOption("Discard transaction")
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.run_screen(
|
||||||
|
WarningScreen,
|
||||||
|
title=_("Transaction Problem"),
|
||||||
|
status_headline=None,
|
||||||
|
# TRANSLATOR_NOTE: The transaction/psbt has an error but does not seem to be malicious.
|
||||||
|
text=_("This transaction claims too many keys for one of its outputs."),
|
||||||
|
button_data=[self.DISCARD],
|
||||||
|
show_back_button=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# We're done with this PSBT. Route back to MainMenuView, which clears all
|
||||||
|
# ephemeral data (except in-memory seeds).
|
||||||
|
# Set clear_history to disable returning via BACK button.
|
||||||
|
return Destination(MainMenuView, clear_history=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class PSBTMixedDerivationPathTypesView(View):
|
||||||
|
"""
|
||||||
|
Reached when an input or output describes its keys in both derivation path maps at
|
||||||
|
once (PSBTMixedDerivationPathTypesError).
|
||||||
|
|
||||||
|
We view this as a strange / buggy psbt and do not try to decide whether it is
|
||||||
|
malicious. We do not allow the user to continue, but only give this the "Warning"
|
||||||
|
level.
|
||||||
|
"""
|
||||||
|
DISCARD = ButtonOption("Discard transaction")
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.run_screen(
|
||||||
|
WarningScreen,
|
||||||
|
title=_("Transaction Problem"),
|
||||||
|
status_headline=None,
|
||||||
|
# TRANSLATOR_NOTE: The transaction/psbt has an error but does not seem to be malicious.
|
||||||
|
text=_("This transaction claims taproot and non-taproot keys for the same script."),
|
||||||
|
button_data=[self.DISCARD],
|
||||||
|
show_back_button=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# We're done with this PSBT. Route back to MainMenuView, which clears all
|
||||||
|
# ephemeral data (except in-memory seeds).
|
||||||
|
# Set clear_history to disable returning via BACK button.
|
||||||
|
return Destination(MainMenuView, clear_history=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class PSBTOutputOwnershipContradictionView(View):
|
||||||
|
"""
|
||||||
|
Reached when the psbt's account of who an output pays contradicts the script that
|
||||||
|
output commits to (see PSBTOutputOwnershipContradictionError).
|
||||||
|
|
||||||
|
We view this as an attack. We do not allow the user to continue and give this the
|
||||||
|
"Dire Warning" level.
|
||||||
|
"""
|
||||||
|
DISCARD = ButtonOption("Discard transaction")
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.run_screen(
|
||||||
|
DireWarningScreen,
|
||||||
|
title=_("Suspicious Transaction"),
|
||||||
|
status_headline=_("Likely an Attack!"),
|
||||||
|
# TRANSLATOR_NOTE: The transaction/psbt contains a deception that we consider an attack.
|
||||||
|
text=_("This transaction misrepresents where one of its outputs pays."),
|
||||||
|
button_data=[self.DISCARD],
|
||||||
|
show_back_button=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# We're done with this PSBT. Route back to MainMenuView, which clears all
|
||||||
|
# ephemeral data (except in-memory seeds).
|
||||||
# Set clear_history to disable returning via BACK button.
|
# Set clear_history to disable returning via BACK button.
|
||||||
return Destination(MainMenuView, clear_history=True)
|
return Destination(MainMenuView, clear_history=True)
|
||||||
|
|
||||||
@@ -546,23 +616,22 @@ class PSBTInputOwnershipClaimFailedView(View):
|
|||||||
|
|
||||||
class PSBTAddressVerificationFailedView(View):
|
class PSBTAddressVerificationFailedView(View):
|
||||||
"""
|
"""
|
||||||
Reached when a change or self-transfer output fails address verification. Shows a dire
|
Reached from PSBTChangeDetailsView when a multisig change or self-transfer output
|
||||||
warning and discards the psbt to the main menu.
|
could not be verified against the descriptor the user supplied.
|
||||||
|
|
||||||
|
We view this as suspicious but stop short of calling it an attack, since the user may
|
||||||
|
have loaded the wrong wallet's descriptor. We do not allow the user to continue and
|
||||||
|
give this the "Dire Warning" level.
|
||||||
"""
|
"""
|
||||||
def __init__(self, is_change: bool = True, is_multisig: bool = False):
|
def __init__(self, is_change: bool = True):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.is_change = is_change
|
self.is_change = is_change
|
||||||
self.is_multisig = is_multisig
|
|
||||||
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
if self.is_multisig:
|
# TRANSLATOR_NOTE: Variable is either "change" or "self-transfer".
|
||||||
# TRANSLATOR_NOTE: Variable is either "change" or "self-transfer".
|
text = _("Transaction's {} address could not be verified from wallet descriptor.").format(_("change") if self.is_change else _("self-transfer"))
|
||||||
text = _("Transaction's {} address could not be verified from wallet descriptor.").format(_("change") if self.is_change else _("self-transfer"))
|
|
||||||
else:
|
|
||||||
# TRANSLATOR_NOTE: Variable is either "change" or "self-transfer".
|
|
||||||
text = _("Transaction's {} address could not be generated from your seed.").format(_("change") if self.is_change else _("self-transfer"))
|
|
||||||
|
|
||||||
self.run_screen(
|
self.run_screen(
|
||||||
DireWarningScreen,
|
DireWarningScreen,
|
||||||
title=_("Suspicious Transaction"),
|
title=_("Suspicious Transaction"),
|
||||||
@@ -572,8 +641,8 @@ class PSBTAddressVerificationFailedView(View):
|
|||||||
show_back_button=False,
|
show_back_button=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# We're done with this PSBT. Route back to MainMenuView, which clears all ephemeral
|
# We're done with this PSBT. Route back to MainMenuView, which clears all
|
||||||
# data (except in-memory seeds).
|
# ephemeral data (except in-memory seeds).
|
||||||
# Set clear_history to disable returning via BACK button.
|
# Set clear_history to disable returning via BACK button.
|
||||||
return Destination(MainMenuView, clear_history=True)
|
return Destination(MainMenuView, clear_history=True)
|
||||||
|
|
||||||
@@ -661,8 +730,8 @@ class PSBTSignedQRDisplayView(View):
|
|||||||
)
|
)
|
||||||
self.run_screen(QRDisplayScreen, qr_encoder=qr_encoder)
|
self.run_screen(QRDisplayScreen, qr_encoder=qr_encoder)
|
||||||
|
|
||||||
# We're done with this PSBT. Route back to MainMenuView which always
|
# We're done with this PSBT. Route back to MainMenuView, which clears all
|
||||||
# clears all ephemeral data (except in-memory seeds).
|
# ephemeral data (except in-memory seeds).
|
||||||
return Destination(MainMenuView, clear_history=True)
|
return Destination(MainMenuView, clear_history=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from binascii import a2b_base64, unhexlify
|
from binascii import a2b_base64, unhexlify
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from embit import bip32
|
from embit import bip32, script
|
||||||
from embit.ec import PublicKey
|
from embit.ec import PublicKey
|
||||||
|
from embit.hashes import tagged_hash
|
||||||
from embit.networks import NETWORKS
|
from embit.networks import NETWORKS
|
||||||
from embit.psbt import PSBT, DerivationPath, InputScope, OutputScope
|
from embit.psbt import PSBT, DerivationPath, InputScope, OutputScope
|
||||||
|
|
||||||
@@ -27,6 +28,41 @@ class PSBTTestData:
|
|||||||
MULTISIG_NESTED_SEGWIT_1_INPUT = "cHNidP8BADMCAAAAAbtbjTfAfTR/t88dfkGmcZBz0l/uzrLuJEyf0WMHl94vAQAAAAD9////AHEAAABPAQQ1h88EmoSnUYAAAAF+MfPe6kyaEEX91G1HKwksGaixXN6RMBTxIcHKjNYYoQJBzkFqUsm3ttoRsGfx+CMj/77pmyyjitqjWZPG4d3RrxQPuIL/MAAAgAEAAIAAAACAAQAAgE8BBDWHzwRA+hbPgAAAAcRAG6PxSUPbFp8IvKVuNlIQn4W5TK1ceLGdKkxdSfktA8QjWNuOjADhspq0onHkGp117FftE91lAYTev8snQsNRFAPNCiswAACAAQAAgAAAAIABAACATwEENYfPBMtS+WKAAAAB6inmZ+P+TS/JJhI/Fog5q8Rx2Nik9EJVEugTuPSDcI4CbvN4qeJalXMOlJpoKnL9Y64icQtz01M9NG6SOhcG8uQUD4iQRDAAAIABAACAAAAAgAEAAIAAAQBzAgAAAAHCfPUdyeETNxf9pkzBP2oOUfgSISrrZi4uj0boKN2PeQEAAAAA/f///wKwqEIGAQAAABepFOV0lUEDO1EMcpnbm8u5gOS//denhwDh9QUAAAAAF6kUURslNGksU91RDOgpxCM7IQOoQaSHAAAAAAEBIADh9QUAAAAAF6kUURslNGksU91RDOgpxCM7IQOoQaSHAQMEAQAAAAEEIgAgDHlipQ8OV+Wko64bycNh+v4LfTW5d8cTv3T1n4XDS/sBBWlSIQIsxclipQM/Gs3kdO+Mlg1gbcMRf1ukSklJhhQ8iMXpSSEDjvwJUpUWl14h2ma/DH5VeAEhM9PxTz70b6lOAgDiWrshA8FwdXOWiPDUIOtV4aCz/ZxfLr9fwrpHQLCUeAtGctJiU64iBgOO/AlSlRaXXiHaZr8MflV4ASEz0/FPPvRvqU4CAOJauxwPuIL/MAAAgAEAAIAAAACAAQAAgAAAAAAAAAAAIgYCLMXJYqUDPxrN5HTvjJYNYG3DEX9bpEpJSYYUPIjF6UkcA80KKzAAAIABAACAAAAAgAEAAIAAAAAAAAAAACIGA8FwdXOWiPDUIOtV4aCz/ZxfLr9fwrpHQLCUeAtGctJiHA+IkEQwAACAAQAAgAAAAIABAACAAAAAAAAAAAAA"
|
MULTISIG_NESTED_SEGWIT_1_INPUT = "cHNidP8BADMCAAAAAbtbjTfAfTR/t88dfkGmcZBz0l/uzrLuJEyf0WMHl94vAQAAAAD9////AHEAAABPAQQ1h88EmoSnUYAAAAF+MfPe6kyaEEX91G1HKwksGaixXN6RMBTxIcHKjNYYoQJBzkFqUsm3ttoRsGfx+CMj/77pmyyjitqjWZPG4d3RrxQPuIL/MAAAgAEAAIAAAACAAQAAgE8BBDWHzwRA+hbPgAAAAcRAG6PxSUPbFp8IvKVuNlIQn4W5TK1ceLGdKkxdSfktA8QjWNuOjADhspq0onHkGp117FftE91lAYTev8snQsNRFAPNCiswAACAAQAAgAAAAIABAACATwEENYfPBMtS+WKAAAAB6inmZ+P+TS/JJhI/Fog5q8Rx2Nik9EJVEugTuPSDcI4CbvN4qeJalXMOlJpoKnL9Y64icQtz01M9NG6SOhcG8uQUD4iQRDAAAIABAACAAAAAgAEAAIAAAQBzAgAAAAHCfPUdyeETNxf9pkzBP2oOUfgSISrrZi4uj0boKN2PeQEAAAAA/f///wKwqEIGAQAAABepFOV0lUEDO1EMcpnbm8u5gOS//denhwDh9QUAAAAAF6kUURslNGksU91RDOgpxCM7IQOoQaSHAAAAAAEBIADh9QUAAAAAF6kUURslNGksU91RDOgpxCM7IQOoQaSHAQMEAQAAAAEEIgAgDHlipQ8OV+Wko64bycNh+v4LfTW5d8cTv3T1n4XDS/sBBWlSIQIsxclipQM/Gs3kdO+Mlg1gbcMRf1ukSklJhhQ8iMXpSSEDjvwJUpUWl14h2ma/DH5VeAEhM9PxTz70b6lOAgDiWrshA8FwdXOWiPDUIOtV4aCz/ZxfLr9fwrpHQLCUeAtGctJiU64iBgOO/AlSlRaXXiHaZr8MflV4ASEz0/FPPvRvqU4CAOJauxwPuIL/MAAAgAEAAIAAAACAAQAAgAAAAAAAAAAAIgYCLMXJYqUDPxrN5HTvjJYNYG3DEX9bpEpJSYYUPIjF6UkcA80KKzAAAIABAACAAAAAgAEAAIAAAAAAAAAAACIGA8FwdXOWiPDUIOtV4aCz/ZxfLr9fwrpHQLCUeAtGctJiHA+IkEQwAACAAQAAgAAAAIABAACAAAAAAAAAAAAA"
|
||||||
MULTISIG_LEGACY_P2SH_1_INPUT = "cHNidP8BADMCAAAAAarS4QMzScnPvNBT2B1I3h80UrSoNuKd9vZkjSl7j2WKAQAAAAD9////AHQAAABPAQQ1h88BD7iC/4AAAC16oZcoUbg2ksYGYWMICvKISPS51jTZLJ3tu4schhGxcAJ/o4LdLxGZHyVDceUz5n6ZtABk9X6nBk3yz/f+eXxkpQgPuIL/LQAAgE8BBDWHzwEDzQorgAAALaD15sHGuMCJCZiW09JfCCZEKGcn9WtB395d/8vj/WC7Am982IdFkEBytDXikxzoeLV5q3kpHg+bfmsmj7ncr1lgCAPNCistAACATwEENYfPAQ+IkESAAAAtcUAwLhCloJPpswRwhGdyG3KP1kY0VAetU6FdAmNrqQoDKFgD/CXYCi5ZHqx4HImYJZtoxpjx60Ki7kvK4Ean8FkID4iQRC0AAIAAAQBzAgAAAAFOE/9Gl/ZpjWR8ioftAB2GPhLXScZ3TEa2tIJ+EDwmXQAAAAAA/f///wI+TSQYAQAAABepFE+Jek+WpV0vCjnVpkLqKfoiLvPAhwDh9QUAAAAAF6kUdHD4u+bbshVEAVKkqRQxu+KR8FKHaAAAAAEDBAEAAAABBGlSIQLUf/ihLNEohwIQiCxVNGmPROLzi8t9FnV6DmfCuEhXHCEDK1QlMflvscOGZmSDWJi90FPUQvNFTQUbkkRdowbR0RghA2T1w+njATChE10XcsbguMj0J5RIzJ5TVN7sbVckbzUWU64iBgLUf/ihLNEohwIQiCxVNGmPROLzi8t9FnV6DmfCuEhXHBAPuIL/LQAAgAAAAAAAAAAAIgYDZPXD6eMBMKETXRdyxuC4yPQnlEjMnlNU3uxtVyRvNRYQA80KKy0AAIAAAAAAAAAAACIGAytUJTH5b7HDhmZkg1iYvdBT1ELzRU0FG5JEXaMG0dEYEA+IkEQtAACAAAAAAAAAAAAA"
|
MULTISIG_LEGACY_P2SH_1_INPUT = "cHNidP8BADMCAAAAAarS4QMzScnPvNBT2B1I3h80UrSoNuKd9vZkjSl7j2WKAQAAAAD9////AHQAAABPAQQ1h88BD7iC/4AAAC16oZcoUbg2ksYGYWMICvKISPS51jTZLJ3tu4schhGxcAJ/o4LdLxGZHyVDceUz5n6ZtABk9X6nBk3yz/f+eXxkpQgPuIL/LQAAgE8BBDWHzwEDzQorgAAALaD15sHGuMCJCZiW09JfCCZEKGcn9WtB395d/8vj/WC7Am982IdFkEBytDXikxzoeLV5q3kpHg+bfmsmj7ncr1lgCAPNCistAACATwEENYfPAQ+IkESAAAAtcUAwLhCloJPpswRwhGdyG3KP1kY0VAetU6FdAmNrqQoDKFgD/CXYCi5ZHqx4HImYJZtoxpjx60Ki7kvK4Ean8FkID4iQRC0AAIAAAQBzAgAAAAFOE/9Gl/ZpjWR8ioftAB2GPhLXScZ3TEa2tIJ+EDwmXQAAAAAA/f///wI+TSQYAQAAABepFE+Jek+WpV0vCjnVpkLqKfoiLvPAhwDh9QUAAAAAF6kUdHD4u+bbshVEAVKkqRQxu+KR8FKHaAAAAAEDBAEAAAABBGlSIQLUf/ihLNEohwIQiCxVNGmPROLzi8t9FnV6DmfCuEhXHCEDK1QlMflvscOGZmSDWJi90FPUQvNFTQUbkkRdowbR0RghA2T1w+njATChE10XcsbguMj0J5RIzJ5TVN7sbVckbzUWU64iBgLUf/ihLNEohwIQiCxVNGmPROLzi8t9FnV6DmfCuEhXHBAPuIL/LQAAgAAAAAAAAAAAIgYDZPXD6eMBMKETXRdyxuC4yPQnlEjMnlNU3uxtVyRvNRYQA80KKy0AAIAAAAAAAAAAACIGAytUJTH5b7HDhmZkg1iYvdBT1ELzRU0FG5JEXaMG0dEYEA+IkEQtAACAAAAAAAAAAAAA"
|
||||||
|
|
||||||
|
# Unlike the fixtures above, this is a complete psbt: its outputs are intact and it
|
||||||
|
# has its own wallet. Use it wherever a test needs more than one input, or needs
|
||||||
|
# inputs and change at known, differing address indices.
|
||||||
|
#
|
||||||
|
# PSBT Tx and Wallet Details
|
||||||
|
# - Single Sig Wallet P2WPKH (Native Segwit) with no passphrase
|
||||||
|
# - Regtest 394aed14 m/84'/1'/0' tpubDC4rddHCzuinGFEKhiD8A3Ku5GRcNVAz3BitfwL4wn7zKPCya4i2CZLX8uoP8oCdC8VEe4jXb5u9ePCSrRA68YRSgBQra5YzBajyxKFDz4C
|
||||||
|
# - 2 Inputs
|
||||||
|
# - 56,522,834 sats at 1/6
|
||||||
|
# - 1,990,245,069 sats at 1/0
|
||||||
|
# - 4 Outputs
|
||||||
|
# - 1 Output spend to another wallet: 123,456 sats to bcrt1q7cw0wzy8g6mq5qvkpvhnk5gsps5ncy3srp0n2j
|
||||||
|
# - 3 Outputs change
|
||||||
|
# - 1/7 address bcrt1q53j0xwuskuf5gnvynadh0hlazyy8srydlucrhg with amount 123,456 sats
|
||||||
|
# - 1/8 address bcrt1q5gtw3zfp4cx67yk5q42q6j6rfza8aqcwpyyslv with amount 56,399,242 sats
|
||||||
|
# - 1/9 address bcrt1q9rrg7399m43cn0yg4tz0v0ate89jgf2d6kpz7v with amount 1,990,121,477 sats
|
||||||
|
# - Fee 272 sats
|
||||||
|
two_input_seed = Seed("goddess rough corn exclude cream trial fee trumpet million prevent gaze power".split())
|
||||||
|
SINGLE_SIG_NATIVE_SEGWIT_2_INPUTS = "cHNidP8BANgCAAAAAsTXZs3fz/dmGb6M80+jjvJZdYya+cw5bT/dGuhZFdSlAAAAAAD9////qo6xg/UZAvUkcbse1F+C9zbP/FeZNjThx7SCIn6eMCgBAAAAAP3///8EQOIBAAAAAAAWABSkZPM7kLcTRE2En1t33/0RCHgMjQXYnnYAAAAAFgAUKMaPRKXdY4m8iKrE9j+rycskJU1A4gEAAAAAABYAFPYc9wiHRrYKAZYLLztREAwpPBIwipVcAwAAAAAWABSiFuiJIa4NrxLUBVQNS0NIun6DDtoRAABPAQQ1h88DBcQGZIAAAAA+0J+jlNL3dpWwlnBi8Dx+Ipg4e6uvB3HdjzFPX7r9CAOOlAIxgII+/xCcj+XoEenKH7wj5s5wlu7Q7CCZWFLGLhA5Su0UVAAAgAEAAIAAAACAAAEA7QIAAAAEE6njX/fnvn7hbkKIRcxzNYFOSfbCdNeWnd7Fe/1UcQ0BAAAAAP3///8TqeNf9+e+fuFuQohFzHM1gU5J9sJ015ad3sV7/VRxDQMAAAAA/f///xOp41/3575+4W5CiEXMczWBTkn2wnTXlp3exXv9VHENBAAAAAD9////E6njX/fnvn7hbkKIRcxzNYFOSfbCdNeWnd7Fe/1UcQ0GAAAAAP3///8CUnheAwAAAAAWABRCfygPJ+Fjsx4BknYvvm3A3qKn2xJ/XQcAAAAAF6kU1I4TAst5nAj15ey7vwe5cM3OFq+HlhEAAAEBH1J4XgMAAAAAFgAUQn8oDyfhY7MeAZJ2L75twN6ip9sBAwQBAAAAIgYCo7sfm78RQY3B5n0ac/QF8VtMAzFnci+h5D1MtpgRY7oYOUrtFFQAAIABAACAAAAAgAEAAAAGAAAAAAEAcQIAAAABxY7wh0nsfJQfzWrD/9rN9BYsM+iOmPaO6I0ANFgO/PcAAAAAAP3///8CptiUAAAAAAAWABRIm4HhQY/TzOjeWSPRrbuJo9MlW826oHYAAAAAFgAU0z+0L2QSLGtyQTn8FhbCpcI7jbliAQAAAQEfzbqgdgAAAAAWABTTP7QvZBIsa3JBOfwWFsKlwjuNuQEDBAEAAAAiBgITHmebEANk81CraV4xZIpqkNjjw0tIvezl1Ism1NRH3Rg5Su0UVAAAgAEAAIAAAACAAQAAAAAAAAAAIgICuTT7WnuiUTpObjWnZFHzIeEvW9PTB+1LLVFNQJVFeIIYOUrtFFQAAIABAACAAAAAgAEAAAAHAAAAACICAk8f3hpc5C35chgSg+Pe2zZ9IhHREd4aKW2+yAMRIFeqGDlK7RRUAACAAQAAgAAAAIABAAAACQAAAAAAIgIDjt1CjvrnMMnjbmTNKUAYoKEDRbmKjNjbq+6Ppqj3bqQYOUrtFFQAAIABAACAAAAAgAEAAAAIAAAAAA=="
|
||||||
|
|
||||||
|
# Also complete and on the same wallet as the fixture above. Taproot records its
|
||||||
|
# derivation paths in a separate map from the segwit-v0 one, so this is what tests of
|
||||||
|
# that split need.
|
||||||
|
#
|
||||||
|
# PSBT Tx and Wallet Details
|
||||||
|
# - Single Sig Wallet P2TR (Taproot) with no passphrase
|
||||||
|
# - Regtest 394aed14 m/86'/1'/0' tpubDCawGrRg7YdHdFb9p4mmD8GBaZjJegL53FPFRrMkGoLcgLATJfksUs2y1Q7dVzixAkgecazsxEsUuyj3LyDw7eVVYHQyojwrc2hfesK4wXW
|
||||||
|
# - 1 Input
|
||||||
|
# - 3,190,493,401 sats at 1/0
|
||||||
|
# - 2 Outputs
|
||||||
|
# - 1 Output change: 2,871,443,918 sats to bcrt1prz4g6saush37epdwhvwpu78td3q7yfz3xxz37axlx7udck6wracq3rwq30 at 1/1
|
||||||
|
# - 1 Output spend to another wallet: 319,049,328 sats to bcrt1p6p00wazu4nnqac29fvky6vhjnnhku5u2g9njss62rvy7e0yuperq86f5ek
|
||||||
|
# - Fee 155 sats
|
||||||
|
SINGLE_SIG_TAPROOT_WITH_CHANGE = "cHNidP8BAIkCAAAAAf8upuiIWF1VTgC/Q8ZWRrameRigaXpRcQcBe8ye+TK3AQAAAAAXCgAAAs7BJqsAAAAAIlEgGKqNQ7yF4+yFrrscHnjrbEHiJFExhR903ze43FtOH3BwTgQTAAAAACJRINBe93RcrOYO4UVLLE0y8pzvblOKQWcoQ0obCey8nA5GAAAAAE8BBDWHzwNMUx9OgAAAAJdr+WtwWfVa6IPbpKZ4KgRC0clbm11Gl155IPA27n2FAvQCrFGH6Ac2U0Gcy1IH5f5ltgUBDz2+fe8iqL6JzZdgEDlK7RRWAACAAQAAgAAAAIAAAQB9AgAAAAGAKOOUFIzw9pbRDaZ7F0DYhLImrdMn//OSm++ff5VNdAAAAAAAAQAAAAKsjLwAAAAAABYAFKEcuxvXmB3rWHSqSviP5mrKMZoL2RArvgAAAAAiUSBGU0Lg5fx/ECsB1Z4ZUqXQFSLFnlmpm0rm5R2l599h2AAAAAABASvZECu+AAAAACJRIEZTQuDl/H8QKwHVnhlSpdAVIsWeWambSublHaXn32HYAQMEAAAAACEWF7hZVn7pIDR429kAn/WDeQiWjZey1iGHztsL1H83QLMZADlK7RRWAACAAQAAgAAAAIABAAAAAAAAAAEXIBe4WVZ+6SA0eNvZAJ/1g3kIlo2XstYhh87bC9R/N0CzACEHbJdqWyMxF2eOPr6YRXUJmry04HUbgKyeM2IZeG+NI9AZADlK7RRWAACAAQAAgAAAAIABAAAAAQAAAAEFIGyXalsjMRdnjj6+mEV1CZq8tOB1G4CsnjNiGXhvjSPQAAA="
|
||||||
|
|
||||||
SINGLE_SIG_INPUTS = [
|
SINGLE_SIG_INPUTS = [
|
||||||
SINGLE_SIG_NATIVE_SEGWIT_1_INPUT,
|
SINGLE_SIG_NATIVE_SEGWIT_1_INPUT,
|
||||||
SINGLE_SIG_NESTED_SEGWIT_1_INPUT,
|
SINGLE_SIG_NESTED_SEGWIT_1_INPUT,
|
||||||
@@ -81,6 +117,13 @@ class PSBTTestData:
|
|||||||
MULTISIG_NESTED_SEGWIT_RECEIVE = "01030890d0030000000000010417a9141d3c080d4b05358b8cc439ef12534250563f34a08700"
|
MULTISIG_NESTED_SEGWIT_RECEIVE = "01030890d0030000000000010417a9141d3c080d4b05358b8cc439ef12534250563f34a08700"
|
||||||
MULTISIG_LEGACY_P2SH_RECEIVE = "01030890d0030000000000010417a914fa70ebb69e283b493770c5a8fa19ec76da321de68700"
|
MULTISIG_LEGACY_P2SH_RECEIVE = "01030890d0030000000000010417a914fa70ebb69e283b493770c5a8fa19ec76da321de68700"
|
||||||
|
|
||||||
|
# The same output as MULTISIG_NATIVE_SEGWIT_RECEIVE, but populated the way a
|
||||||
|
# coordinator that also holds the recipient's descriptor populates it: the 2-of-3
|
||||||
|
# script the output pays, plus a derivation path entry for each of its cosigners.
|
||||||
|
# Built from recipient_seed, recipient_multisig_key_2 and recipient_multisig_key_3,
|
||||||
|
# each at m/48h/1h/0h/2h/0/0.
|
||||||
|
MULTISIG_NATIVE_SEGWIT_RECEIVE_ANNOTATED = "0101695221027d99f92952795d306ef9b87a9d16c94bedc4b17613042a6f1b80af0e9c7839a32103208d94f8b4df19bcb76c309887904920064ccc417225520790426cb0e40fe6552103d36ac409c6198fee6eae72bf5c38424f3f2e2077a67aaf14d535fd1021b97c4953ae2202027d99f92952795d306ef9b87a9d16c94bedc4b17613042a6f1b80af0e9c7839a31c0f7d3df0300000800100008000000080020000800000000000000000220203208d94f8b4df19bcb76c309887904920064ccc417225520790426cb0e40fe6551ccd063d44300000800100008000000080020000800000000000000000220203d36ac409c6198fee6eae72bf5c38424f3f2e2077a67aaf14d535fd1021b97c491c2f54d8a930000080010000800000008002000080000000000000000001030890d003000000000001042200200936ff1943bbe5c037ad9e9839ca3effe24d8b22fd38cc2601c9007cc0f210a100"
|
||||||
|
|
||||||
ALL_EXTERNAL_OUTPUTS = [
|
ALL_EXTERNAL_OUTPUTS = [
|
||||||
SINGLE_SIG_NATIVE_SEGWIT_RECEIVE,
|
SINGLE_SIG_NATIVE_SEGWIT_RECEIVE,
|
||||||
SINGLE_SIG_NESTED_SEGWIT_RECEIVE,
|
SINGLE_SIG_NESTED_SEGWIT_RECEIVE,
|
||||||
@@ -158,7 +201,37 @@ def foreign_public_key(derivation_path: str = "m/84h/1h/0h/0/0", seed: Seed = No
|
|||||||
return root_for_seed(seed).derive(derivation_path).get_public_key()
|
return root_for_seed(seed).derive(derivation_path).get_public_key()
|
||||||
|
|
||||||
|
|
||||||
def claim_seed_owns_key(scope: InputScope | OutputScope, claimed_derivation_path: str, public_key: PublicKey, seed: Seed = None, is_taproot: bool = False):
|
def tapleaf_hash(public_key: PublicKey, leaf_version: int = 0xC0) -> bytes:
|
||||||
|
"""
|
||||||
|
The BIP-341 leaf hash of the simplest tapscript (a lone key checksig), built the same
|
||||||
|
way embit builds it when it signs a script path spend.
|
||||||
|
"""
|
||||||
|
OP_PUSHBYTES_32 = b"\x20"
|
||||||
|
OP_CHECKSIG = b"\xac"
|
||||||
|
leaf_script = script.Script(OP_PUSHBYTES_32 + public_key.xonly() + OP_CHECKSIG)
|
||||||
|
return tagged_hash("TapLeaf", bytes([leaf_version]) + leaf_script.serialize())
|
||||||
|
|
||||||
|
|
||||||
|
# BIP-341's provably unspendable internal key (aka "NUMS" point: "Nothing Up My Sleeve"):
|
||||||
|
# the SHA256 of the standard uncompressed encoding of the secp256k1 generator point,
|
||||||
|
# lifted to a curve point. A taproot address built for script path spends only uses this
|
||||||
|
# as its internal key, so that nobody holds a key path to it.
|
||||||
|
NUMS_INTERNAL_KEY = PublicKey.from_xonly(unhexlify("50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"))
|
||||||
|
|
||||||
|
|
||||||
|
def p2tr_with_script_tree(internal_public_key: PublicKey, merkle_root: bytes) -> script.Script:
|
||||||
|
"""
|
||||||
|
The scriptPubKey of a taproot output whose internal key is tweaked by a script tree's
|
||||||
|
merkle root, built the same way embit builds the empty-tree form. embit's script.p2tr
|
||||||
|
cannot build this one: it asks the script tree for its own tweak, and embit defines no
|
||||||
|
script tree type to ask.
|
||||||
|
"""
|
||||||
|
OP_1 = b"\x51" # segwit version byte
|
||||||
|
OP_PUSHBYTES_32 = b"\x20"
|
||||||
|
return script.Script(OP_1 + OP_PUSHBYTES_32 + internal_public_key.taproot_tweak(merkle_root).xonly())
|
||||||
|
|
||||||
|
|
||||||
|
def claim_seed_owns_key(scope: InputScope | OutputScope, claimed_derivation_path: str, public_key: PublicKey, seed: Seed = None, is_taproot: bool = False, leaf_hashes: list = None):
|
||||||
"""
|
"""
|
||||||
Writes a derivation into the scope claiming that seed owns public_key at
|
Writes a derivation into the scope claiming that seed owns public_key at
|
||||||
claimed_derivation_path.
|
claimed_derivation_path.
|
||||||
@@ -167,6 +240,9 @@ def claim_seed_owns_key(scope: InputScope | OutputScope, claimed_derivation_path
|
|||||||
ties a fingerprint to the key written beside it. The fingerprint is all it takes, and
|
ties a fingerprint to the key written beside it. The fingerprint is all it takes, and
|
||||||
that is published in every psbt the seed has ever been sent. So pass a public_key the
|
that is published in every psbt the seed has ever been sent. So pass a public_key the
|
||||||
seed does not own and the result is a psbt asserting ownership that does not exist.
|
seed does not own and the result is a psbt asserting ownership that does not exist.
|
||||||
|
|
||||||
|
For taproot, leaf_hashes names the leaf scripts the key is used in. An entry naming
|
||||||
|
none of them is claiming to be the output's internal key; see tapleaf_hash.
|
||||||
"""
|
"""
|
||||||
if seed is None:
|
if seed is None:
|
||||||
seed = PSBTTestData.seed
|
seed = PSBTTestData.seed
|
||||||
@@ -175,6 +251,6 @@ def claim_seed_owns_key(scope: InputScope | OutputScope, claimed_derivation_path
|
|||||||
root_for_seed(seed).my_fingerprint, bip32.parse_path(claimed_derivation_path))
|
root_for_seed(seed).my_fingerprint, bip32.parse_path(claimed_derivation_path))
|
||||||
|
|
||||||
if is_taproot:
|
if is_taproot:
|
||||||
scope.taproot_bip32_derivations[public_key] = ([], derivation_path)
|
scope.taproot_bip32_derivations[public_key] = (leaf_hashes or [], derivation_path)
|
||||||
else:
|
else:
|
||||||
scope.bip32_derivations[public_key] = derivation_path
|
scope.bip32_derivations[public_key] = derivation_path
|
||||||
|
|||||||
@@ -449,10 +449,11 @@ def generate_screenshots(locale):
|
|||||||
ScreenshotConfig(psbt_views.PSBTOverviewView, screenshot_name="PSBTOverviewView_op_return", mock_context_manager=mock_psbt_with_op_return_loaded),
|
ScreenshotConfig(psbt_views.PSBTOverviewView, screenshot_name="PSBTOverviewView_op_return", mock_context_manager=mock_psbt_with_op_return_loaded),
|
||||||
ScreenshotConfig(psbt_views.PSBTOpReturnView, screenshot_name="PSBTOpReturnView_text", mock_context_manager=mock_psbt_with_op_return_loaded),
|
ScreenshotConfig(psbt_views.PSBTOpReturnView, screenshot_name="PSBTOpReturnView_text", mock_context_manager=mock_psbt_with_op_return_loaded),
|
||||||
ScreenshotConfig(psbt_views.PSBTOpReturnView, screenshot_name="PSBTOpReturnView_raw_hex_data", mock_context_manager=mock_psbt_with_op_return_raw_bytes_loaded),
|
ScreenshotConfig(psbt_views.PSBTOpReturnView, screenshot_name="PSBTOpReturnView_raw_hex_data", mock_context_manager=mock_psbt_with_op_return_raw_bytes_loaded),
|
||||||
ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True, is_multisig=False), screenshot_name="PSBTAddressVerificationFailedView_singlesig_change"),
|
ScreenshotConfig(psbt_views.PSBTSurplusDerivationPathsView),
|
||||||
ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=False, is_multisig=False), screenshot_name="PSBTAddressVerificationFailedView_singlesig_selftransfer"),
|
ScreenshotConfig(psbt_views.PSBTMixedDerivationPathTypesView),
|
||||||
ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True, is_multisig=True), screenshot_name="PSBTAddressVerificationFailedView_multisig_change"),
|
ScreenshotConfig(psbt_views.PSBTOutputOwnershipContradictionView),
|
||||||
ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=False, is_multisig=True), screenshot_name="PSBTAddressVerificationFailedView_multisig_selftransfer"),
|
ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True), screenshot_name="PSBTAddressVerificationFailedView_multisig_change"),
|
||||||
|
ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=False), screenshot_name="PSBTAddressVerificationFailedView_multisig_selftransfer"),
|
||||||
ScreenshotConfig(psbt_views.PSBTOutputOwnershipClaimFailedView),
|
ScreenshotConfig(psbt_views.PSBTOutputOwnershipClaimFailedView),
|
||||||
ScreenshotConfig(psbt_views.PSBTInputOwnershipClaimFailedView),
|
ScreenshotConfig(psbt_views.PSBTInputOwnershipClaimFailedView),
|
||||||
ScreenshotConfig(psbt_views.PSBTSeedCannotSignView),
|
ScreenshotConfig(psbt_views.PSBTSeedCannotSignView),
|
||||||
|
|||||||
+93
-23
@@ -1,10 +1,11 @@
|
|||||||
from binascii import a2b_base64
|
from binascii import a2b_base64
|
||||||
|
|
||||||
from embit.psbt import PSBT
|
from embit import bip32, script
|
||||||
|
from embit.psbt import PSBT, DerivationPath
|
||||||
|
|
||||||
from base import FlowTest, FlowStep
|
from base import FlowTest, FlowStep
|
||||||
from psbt_testing_util import (PSBTTestData, claim_seed_owns_key, create_output,
|
from psbt_testing_util import (PSBTTestData, claim_seed_owns_key, create_output,
|
||||||
foreign_public_key)
|
foreign_public_key, root_for_seed)
|
||||||
|
|
||||||
from seedsigner.controller import Controller
|
from seedsigner.controller import Controller
|
||||||
from seedsigner.views.view import MainMenuView
|
from seedsigner.views.view import MainMenuView
|
||||||
@@ -24,24 +25,7 @@ class TestPSBTFlows(FlowTest):
|
|||||||
since the PSBT is not a self transfer it should enter the PSBTAddressDetailsView flow
|
since the PSBT is not a self transfer it should enter the PSBTAddressDetailsView flow
|
||||||
"""
|
"""
|
||||||
def load_psbt_into_decoder(view: scan_views.ScanView):
|
def load_psbt_into_decoder(view: scan_views.ScanView):
|
||||||
"""
|
view.decoder.add_data(PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_2_INPUTS)
|
||||||
PSBT Tx and Wallet Details
|
|
||||||
- Single Sig Wallet P2WPKH (Native Segwit) with no passphrase
|
|
||||||
- Regtest c751dc07 m/84'/1'/0' tpubDDZBrnxMxbVzqt8EoEiABPxeKzFWma5pra5UEbg3Wst1hrwr6feuvcy7Sov7cpuYx94ypuy1PQ9NDNoQagFs37wGALzLb5Ei3FvyJWPPPKZ
|
|
||||||
- 2 Inputs
|
|
||||||
- 56,522,834 sats
|
|
||||||
- 1,990,245,069 sats
|
|
||||||
- 4 Outputs
|
|
||||||
- 1 Output to another wallet (bcrt1q7cw0wzy8g6mq5qvkpvhnk5gsps5ncy3srp0n2j) of 123,456 sats
|
|
||||||
- 3 Outputs change
|
|
||||||
- 3 outputs to emulate a fake mix to increase privacy
|
|
||||||
- Change addresses are index 1/7, 1/8, 1/9
|
|
||||||
- 1/7 address bcrt1q53j0xwuskuf5gnvynadh0hlazyy8srydlucrhg with amount 123,456 sats
|
|
||||||
- 1/8 address bcrt1q5gtw3zfp4cx67yk5q42q6j6rfza8aqcwpyyslv with amount 1,990,121,477 sats
|
|
||||||
- 1/9 address bcrt1q9rrg7399m43cn0yg4tz0v0ate89jgf2d6kpz7v with amount 56,399,242 sats
|
|
||||||
- Fee 272 sats
|
|
||||||
"""
|
|
||||||
view.decoder.add_data("cHNidP8BANgCAAAAAsTXZs3fz/dmGb6M80+jjvJZdYya+cw5bT/dGuhZFdSlAAAAAAD9////qo6xg/UZAvUkcbse1F+C9zbP/FeZNjThx7SCIn6eMCgBAAAAAP3///8EQOIBAAAAAAAWABSkZPM7kLcTRE2En1t33/0RCHgMjQXYnnYAAAAAFgAUKMaPRKXdY4m8iKrE9j+rycskJU1A4gEAAAAAABYAFPYc9wiHRrYKAZYLLztREAwpPBIwipVcAwAAAAAWABSiFuiJIa4NrxLUBVQNS0NIun6DDtoRAABPAQQ1h88DBcQGZIAAAAA+0J+jlNL3dpWwlnBi8Dx+Ipg4e6uvB3HdjzFPX7r9CAOOlAIxgII+/xCcj+XoEenKH7wj5s5wlu7Q7CCZWFLGLhA5Su0UVAAAgAEAAIAAAACAAAEA7QIAAAAEE6njX/fnvn7hbkKIRcxzNYFOSfbCdNeWnd7Fe/1UcQ0BAAAAAP3///8TqeNf9+e+fuFuQohFzHM1gU5J9sJ015ad3sV7/VRxDQMAAAAA/f///xOp41/3575+4W5CiEXMczWBTkn2wnTXlp3exXv9VHENBAAAAAD9////E6njX/fnvn7hbkKIRcxzNYFOSfbCdNeWnd7Fe/1UcQ0GAAAAAP3///8CUnheAwAAAAAWABRCfygPJ+Fjsx4BknYvvm3A3qKn2xJ/XQcAAAAAF6kU1I4TAst5nAj15ey7vwe5cM3OFq+HlhEAAAEBH1J4XgMAAAAAFgAUQn8oDyfhY7MeAZJ2L75twN6ip9sBAwQBAAAAIgYCo7sfm78RQY3B5n0ac/QF8VtMAzFnci+h5D1MtpgRY7oYOUrtFFQAAIABAACAAAAAgAEAAAAGAAAAAAEAcQIAAAABxY7wh0nsfJQfzWrD/9rN9BYsM+iOmPaO6I0ANFgO/PcAAAAAAP3///8CptiUAAAAAAAWABRIm4HhQY/TzOjeWSPRrbuJo9MlW826oHYAAAAAFgAU0z+0L2QSLGtyQTn8FhbCpcI7jbliAQAAAQEfzbqgdgAAAAAWABTTP7QvZBIsa3JBOfwWFsKlwjuNuQEDBAEAAAAiBgITHmebEANk81CraV4xZIpqkNjjw0tIvezl1Ism1NRH3Rg5Su0UVAAAgAEAAIAAAACAAQAAAAAAAAAAIgICuTT7WnuiUTpObjWnZFHzIeEvW9PTB+1LLVFNQJVFeIIYOUrtFFQAAIABAACAAAAAgAEAAAAHAAAAACICAk8f3hpc5C35chgSg+Pe2zZ9IhHREd4aKW2+yAMRIFeqGDlK7RRUAACAAQAAgAAAAIABAAAACQAAAAAAIgIDjt1CjvrnMMnjbmTNKUAYoKEDRbmKjNjbq+6Ppqj3bqQYOUrtFFQAAIABAACAAAAAgAEAAAAIAAAAAA==")
|
|
||||||
|
|
||||||
def load_seed_into_decoder(view: scan_views.ScanView):
|
def load_seed_into_decoder(view: scan_views.ScanView):
|
||||||
view.decoder.add_data("080115060387063104071857067618681125136207731354")
|
view.decoder.add_data("080115060387063104071857067618681125136207731354")
|
||||||
@@ -193,9 +177,9 @@ class TestPSBTFlows(FlowTest):
|
|||||||
|
|
||||||
class TestPSBTOwnershipClaimRouting(FlowTest):
|
class TestPSBTOwnershipClaimRouting(FlowTest):
|
||||||
"""
|
"""
|
||||||
A psbt carrying an ownership claim that does not hold up is rejected while it is being
|
A psbt whose own description of itself does not hold up is refused during parsing,
|
||||||
parsed, before the user is shown anything about the transaction. These cover the
|
before the user is shown anything about the transaction. These cover what the user
|
||||||
routing that turns that rejection into a warning screen instead of a crash.
|
meets when that happens: a warning screen that ends the flow, not a crash.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _load_psbt_for_signing(self, psbt: PSBT, seed: Seed = None):
|
def _load_psbt_for_signing(self, psbt: PSBT, seed: Seed = None):
|
||||||
@@ -250,6 +234,92 @@ class TestPSBTOwnershipClaimRouting(FlowTest):
|
|||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def test_surplus_derivation_paths_terminate_signing_flow(self):
|
||||||
|
"""
|
||||||
|
When an output names more derivation paths than its script can use, nothing in
|
||||||
|
the psbt says which key it actually pays, so parsing refuses it.
|
||||||
|
|
||||||
|
The parser tests cover why that shape is refusable. This one covers what the user
|
||||||
|
gets: a warning that ends the flow, rather than a crash, and without first being
|
||||||
|
walked through the details of a transaction about to be discarded.
|
||||||
|
"""
|
||||||
|
other_root = root_for_seed(PSBTTestData.recipient_seed)
|
||||||
|
|
||||||
|
psbt = PSBT.parse(a2b_base64(PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_2_INPUTS))
|
||||||
|
|
||||||
|
# Output 2 pays a stranger and carries no derivation path entries of its own.
|
||||||
|
# Give it two, so nothing in the psbt says which key it pays.
|
||||||
|
for i in range(2):
|
||||||
|
derivation_path = bip32.parse_path(f"m/84h/1h/0h/0/{i}")
|
||||||
|
psbt.outputs[2].bip32_derivations[other_root.derive(derivation_path).get_public_key()] = \
|
||||||
|
DerivationPath(other_root.my_fingerprint, derivation_path)
|
||||||
|
|
||||||
|
self._load_psbt_for_signing(psbt, seed=PSBTTestData.two_input_seed)
|
||||||
|
|
||||||
|
self.run_sequence([
|
||||||
|
FlowStep(psbt_views.PSBTSelectSeedView, screen_return_value=0),
|
||||||
|
FlowStep(psbt_views.PSBTOverviewView, is_redirect=True),
|
||||||
|
FlowStep(psbt_views.PSBTSurplusDerivationPathsView, button_data_selection=psbt_views.PSBTSurplusDerivationPathsView.DISCARD),
|
||||||
|
FlowStep(MainMenuView),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def test_output_ownership_contradiction_terminates_signing_flow(self):
|
||||||
|
"""
|
||||||
|
When a psbt marks an output as paying us while its script pays a stranger, the
|
||||||
|
two cannot both be true, so parsing refuses it.
|
||||||
|
|
||||||
|
The parser tests cover which shapes qualify. This one covers what the user gets:
|
||||||
|
a warning that ends the flow, before any transaction detail is rendered.
|
||||||
|
"""
|
||||||
|
psbt = PSBT.parse(a2b_base64(PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_2_INPUTS))
|
||||||
|
|
||||||
|
# Output 0 is the wallet's own change. Repoint its script at a stranger but leave
|
||||||
|
# its derivation path entry in place, so the psbt still names a key this seed
|
||||||
|
# owns on an output that no longer pays it. Note that psbt.tx is rebuilt on every
|
||||||
|
# access, so the scriptPubKey has to be set on the output scope itself.
|
||||||
|
psbt.outputs[0].script_pubkey = script.p2wpkh(foreign_public_key())
|
||||||
|
|
||||||
|
self._load_psbt_for_signing(psbt, seed=PSBTTestData.two_input_seed)
|
||||||
|
|
||||||
|
self.run_sequence([
|
||||||
|
FlowStep(psbt_views.PSBTSelectSeedView, screen_return_value=0),
|
||||||
|
FlowStep(psbt_views.PSBTOverviewView, is_redirect=True),
|
||||||
|
FlowStep(psbt_views.PSBTOutputOwnershipContradictionView, button_data_selection=psbt_views.PSBTOutputOwnershipContradictionView.DISCARD),
|
||||||
|
FlowStep(MainMenuView),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def test_mixed_derivation_path_types_terminate_signing_flow(self):
|
||||||
|
"""
|
||||||
|
An input or output filling both derivation path maps at once is refused on that
|
||||||
|
shape alone. The check lives in the ownership scan, which runs over inputs as well
|
||||||
|
as outputs, so this one plants the shape on an input, the side the parser tests do
|
||||||
|
not cover.
|
||||||
|
|
||||||
|
It ends the flow at its own warning before any transaction detail is rendered.
|
||||||
|
"""
|
||||||
|
other_root = root_for_seed(PSBTTestData.recipient_seed)
|
||||||
|
|
||||||
|
psbt = PSBT.parse(a2b_base64(PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_2_INPUTS))
|
||||||
|
|
||||||
|
# Input 0 already carries a segwit-v0 entry. Add a taproot one beside it, on a
|
||||||
|
# stranger's key so nothing here claims this seed: the refusal is on the shape
|
||||||
|
# alone, not on an ownership claim.
|
||||||
|
derivation_path = bip32.parse_path("m/86h/1h/0h/0/0")
|
||||||
|
psbt.inputs[0].taproot_bip32_derivations[foreign_public_key()] = (
|
||||||
|
[], DerivationPath(other_root.my_fingerprint, derivation_path))
|
||||||
|
|
||||||
|
self._load_psbt_for_signing(psbt, seed=PSBTTestData.two_input_seed)
|
||||||
|
|
||||||
|
self.run_sequence([
|
||||||
|
FlowStep(psbt_views.PSBTSelectSeedView, screen_return_value=0),
|
||||||
|
FlowStep(psbt_views.PSBTOverviewView, is_redirect=True),
|
||||||
|
FlowStep(psbt_views.PSBTMixedDerivationPathTypesView, button_data_selection=psbt_views.PSBTMixedDerivationPathTypesView.DISCARD),
|
||||||
|
FlowStep(MainMenuView),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
def test_wrong_seed_routes_back_to_seed_selection_flow(self):
|
def test_wrong_seed_routes_back_to_seed_selection_flow(self):
|
||||||
"""
|
"""
|
||||||
The wrong seed for a psbt redirects before any transaction detail is rendered and
|
The wrong seed for a psbt redirects before any transaction detail is rendered and
|
||||||
|
|||||||
+949
-35
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user