What changed, and why it matters
This commit adds a new 'WIF Store' feature to the COLDCARD firmware. It lets users import external Bitcoin private keys (in WIF format) and use them to sign PSBT transactions and messages, alongside the device's normal seed-derived keys. The change is a deliberate new feature, not a hidden bug, but it touches sensitive code paths: private key storage, PSBT signing, address ownership checks, and message signing. There is no vendor statement in the commit or supplied references calling this a security fix or vulnerability.
Treat this as a feature-security review: verify that WIF private keys are encrypted at rest, that import/signing requires explicit user authorization, that WIF-store keys cannot be extracted by unauthorized USB/NFC/SD operations, and that the 'hobbled' read-only restrictions cannot be bypassed. Review tests for negative cases (wrong chain, invalid WIF, over-max imports) and ensure secure-clear on seed wipe or trick-pin wipe.
Security signals we found
New feature adds persistent storage of arbitrary private keys in device settings
PSBT signing path now branches on whether a required pubkey is in the WIF store
Address ownership verification now recognizes WIF-store addresses as 'ours'
Message signing can use imported WIF keys instead of seed-derived keys
WIF keys are displayed/exported in plaintext via QR, NFC, and microSD
Hobbled mode restricts WIF store to read-only, indicating vendor awareness of privilege concerns
No vendor statement in commit describes this as a security fix or vulnerability
Evidence from the diff
The patch introduces shared/wif.py, a new menu-driven WIF store. Imported keys are saved as hex (pubkey, privkey) tuples in settings under ‘wifs’ (max 30). During PSBT parsing, init_wif_store() loads these keys and psbtObject.parse_subpaths()/determine_my_signing_key() now treats a known WIF pubkey as ‘ours’, allowing signature generation with node_from_privkey() instead of seed derivation. Message signing (auth.py/msgsign.py) and address ownership search (ownership.py) are also updated to use WIF-store keys. UI warnings are added when WIF keys are used, and hobbled/restricted modes limit import/delete. The commit is a feature addition with broad changes across auth, psbt, ownership, msgsign, stash, trick_pins, decoders, and UI/test files.
Changed components
shared/wif.pyshared/psbt.pyshared/auth.pyshared/msgsign.pyshared/ownership.pyshared/nvstore.pyshared/decoders.pyshared/flow.pyshared/stash.pyshared/trick_pins.pyshared/utils.pyshared/nfc.pyInspect captured patch +1243 / −82
diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md
index f80b06a..0432d53 100644
--- a/releases/Next-ChangeLog.md
+++ b/releases/Next-ChangeLog.md
@@ -8,6 +8,7 @@ This lists the new changes that have not yet been published in a normal release.
- Requires a carefully crafted PSBT that does not represent a monetary transaction, but instead is demonstrating
control over the keys for a list of UTXO, and commits to a short text message.
- Read more [here](https://github.com/Coldcard/firmware/blob/master/docs/proof-of-reserves-bip-322.md).
+- New Feature: WIF Store. Ability to import foreign WIF keys and use them for PSBT signing.
- New Feature: Export [BIP-380](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki) extended key expression.
Navigate to "Advanced/Tools -> Export Wallet -> Key Expression"
- New Feature: Transaction Input Explorer. Shows data about UTXO(s) being spent. Press (2) before approving
diff --git a/shared/auth.py b/shared/auth.py
index 76900b3..7f998d3 100644
--- a/shared/auth.py
+++ b/shared/auth.py
@@ -16,7 +16,7 @@ from serializations import ser_uint256, SIGHASH_ALL
from ux import ux_show_story, abort_and_goto, ux_dramatic_pause, ux_clear_keys, ux_confirm, the_ux
from ux import show_qr_code, OK, X, abort_and_push, AbortInteraction, ux_input_text, ux_enter_number
from usb import CCBusyError
-from utils import (HexWriter, xfp2str, problem_file_line, cleanup_deriv_path, B2A,
+from utils import (HexWriter, xfp2str, problem_file_line, cleanup_deriv_path, B2A, node_from_privkey,
show_single_address, keypath_to_str, seconds2human_readable)
from psbt import psbtObject, FatalPSBTIssue, FraudulentChangeOutput
from files import CardSlot, CardMissingError
@@ -131,7 +131,7 @@ Press %s to continue, otherwise %s to cancel.''' % (OK, X)
class ApproveMessageSign(UserAuthorizedAction):
def __init__(self, text, subpath, addr_fmt, approved_cb=None,
- msg_sign_request=None, only_printable=True):
+ msg_sign_request=None, only_printable=True, privkey=None):
super().__init__()
is_json = False
@@ -146,13 +146,18 @@ class ApproveMessageSign(UserAuthorizedAction):
self.subpath = cleanup_deriv_path(subpath)
self.addr_fmt = chains.parse_addr_fmt_str(addr_fmt)
self.approved_cb = approved_cb
+ self.privkey = privkey
from glob import dis
dis.fullscreen('Wait...')
- with stash.SensitiveValues() as sv:
- node = sv.derive_path(self.subpath)
- self.address = sv.chain.address(node, self.addr_fmt)
+ if self.privkey:
+ node = node_from_privkey(self.privkey)
+ self.address = chains.current_chain().address(node, self.addr_fmt)
+ else:
+ with stash.SensitiveValues() as sv:
+ node = sv.derive_path(self.subpath)
+ self.address = sv.chain.address(node, self.addr_fmt)
dis.progress_bar_show(1)
@@ -173,7 +178,8 @@ class ApproveMessageSign(UserAuthorizedAction):
else:
# perform signing (progress bar shown)
digest = chains.current_chain().hash_message(self.text.encode())
- self.result, _ = sign_message_digest(digest, self.subpath, "Signing...", self.addr_fmt)
+ self.result, _ = sign_message_digest(digest, self.subpath, "Signing...",
+ self.addr_fmt, pk=self.privkey)
if self.approved_cb:
# for micro sd case
@@ -197,7 +203,7 @@ def sign_msg(text, subpath, addr_fmt):
async def approve_msg_sign(text, subpath, addr_fmt, approved_cb=None,
msg_sign_request=None, kill_menu=False,
- only_printable=True):
+ only_printable=True, privkey=None):
# Ask user if they want to sign some short text message.
UserAuthorizedAction.cleanup()
@@ -208,6 +214,7 @@ async def approve_msg_sign(text, subpath, addr_fmt, approved_cb=None,
approved_cb=approved_cb,
msg_sign_request=msg_sign_request,
only_printable=only_printable,
+ privkey=privkey
)
if kill_menu:
@@ -228,8 +235,6 @@ async def sign_txt_file(filename):
async def done(signature, address, text):
# complete. write out result
- from glob import dis
-
orig_path, basename = filename.rsplit('/', 1)
orig_path += '/'
base = basename.rsplit('.', 1)[0]
@@ -1734,12 +1739,14 @@ class TXInpExplorer(TXExplorer):
psbt_item = ""
if inp.required_key:
+ ws = self.user_auth_action.psbt.wif_store
our = [inp.required_key] if isinstance(inp.required_key, bytes) else inp.required_key
psbt_item += "Our key%s:\n\n" % ("s" if len(our) > 1 else "")
for k in our:
pth = inp.subpaths[k]
- psbt_item += "%s:\n%s\n\n" % (keypath_to_str(pth, prefix="%s/" % xfp2str(pth[0])),
- b2a_hex(k).decode())
+ ws_note = "\n(WIF Store)" if (ws and k in ws) else ""
+ psbt_item += "%s:\n%s%s\n\n" % (keypath_to_str(pth, prefix="%s/" % xfp2str(pth[0])),
+ b2a_hex(k).decode(), ws_note)
M = None
if inp.is_multisig:
diff --git a/shared/decoders.py b/shared/decoders.py
index 2e1505c..0c3ea85 100644
--- a/shared/decoders.py
+++ b/shared/decoders.py
@@ -59,17 +59,9 @@ def decode_secret(got):
if len(got) in (51, 52):
try:
- raw = ngu.codecs.b58_decode(got)
- if raw[0] in (0xef, 0x80):
- testnet = True if raw[0] == 0xef else False
- if len(raw) in (33, 34): # uncompressed pubkey
- compressed = False
- if len(raw) == 34: # compressed pubkey
- assert raw[33] == 0x01
- compressed = True
- sk = raw[1:33]
- kp = ngu.secp256k1.keypair(sk)
- return 'wif', (got, kp, compressed, testnet)
+ from wif import decode_wif
+ kp, testnet, compressed = decode_wif(got)
+ return 'wif', (got, kp, compressed, testnet)
except: pass
taste = got.strip().lower()
diff --git a/shared/drv_entro.py b/shared/drv_entro.py
index 7610767..d4ab62f 100644
--- a/shared/drv_entro.py
+++ b/shared/drv_entro.py
@@ -12,7 +12,7 @@ from menu import MenuItem, MenuSystem
from ubinascii import hexlify as b2a_hex
from ubinascii import b2a_base64
from msgsign import write_sig_file
-from utils import xfp2str, swab32
+from utils import xfp2str, swab32, node_from_privkey
from charcodes import KEY_QR, KEY_NFC, KEY_CANCEL
BIP85_PWD_LEN = 21
@@ -180,7 +180,7 @@ async def drv_entro_step2(_1, picked, _2, just_pick=False):
elif s_mode == 'xprv':
# Raw XPRV value.
ch, pk = new_secret[0:32], new_secret[32:64]
- master_node = ngu.hdnode.HDNode().from_chaincode_privkey(ch, pk)
+ master_node = node_from_privkey(pk, ch)
node = master_node
encoded = stash.SecretStash.encode(xprv=master_node)
diff --git a/shared/flow.py b/shared/flow.py
index f1b07f6..1aef959 100644
--- a/shared/flow.py
+++ b/shared/flow.py
@@ -20,6 +20,7 @@ from paper import make_paper_wallet
from trick_pins import TrickPinMenu
from tapsigner import import_tapsigner_backup_file
from ccc import toggle_ccc_feature, sssp_spending_policy, sssp_feature_menu
+from wif import WIFStore
# useful shortcut keys
from charcodes import KEY_QR, KEY_NFC
@@ -422,6 +423,7 @@ AdvancedNormalMenu = [
MenuItem("Key Teleport (start)", f=kt_start_rx, predicate=version.has_qr),
MenuItem("Spending Policy", menu=SpendingPolicySubMenu,shortcut='s',predicate=has_real_secret),
MenuItem('Paper Wallets', f=make_paper_wallet),
+ MenuItem('WIF Store', menu=WIFStore.make_menu),
MenuItem('NFC Tools', predicate=nfc_enabled, menu=NFCToolsMenu, shortcut=KEY_NFC),
MenuItem("Danger Zone", menu=DangerZoneMenu, shortcut='z'),
]
@@ -548,6 +550,7 @@ HobbledAdvancedMenu = [
MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu, predicate=sssp_related_keys),
MenuItem('Paper Wallets', f=make_paper_wallet),
MenuItem('NFC Tools', predicate=nfc_enabled, menu=HobbledNFCToolsMenu, shortcut=KEY_NFC),
+ MenuItem('WIF Store', menu=WIFStore.make_menu, predicate=sssp_related_keys),
MenuItem('Show %s Version' % ("Firmware" if version.has_qwerty else "FW"), f=show_version),
MenuItem("Destroy Seed", f=clear_seed, predicate=has_real_secret),
]
diff --git a/shared/manifest.py b/shared/manifest.py
index 3ed356a..4128769 100644
--- a/shared/manifest.py
+++ b/shared/manifest.py
@@ -59,6 +59,7 @@ freeze_as_mpy('', [
'version.py',
'wallet.py',
'web2fa.py',
+ 'wif.py',
'xor_seed.py'
], opt=0)
diff --git a/shared/msgsign.py b/shared/msgsign.py
index 144dc65..d95acd3 100644
--- a/shared/msgsign.py
+++ b/shared/msgsign.py
@@ -12,7 +12,7 @@ from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH
from charcodes import KEY_QR, KEY_NFC, KEY_CANCEL
from ux import (ux_show_story, OK, ux_enter_bip32_index, ux_input_text, the_ux,
import_export_prompt, ux_aborted)
-from utils import problem_file_line, to_ascii_printable, show_single_address
+from utils import problem_file_line, to_ascii_printable, show_single_address, node_from_privkey
from files import CardSlot, CardMissingError, needs_microsd
def rfc_signature_template(msg, addr, sig):
@@ -381,7 +381,7 @@ def sign_message_digest(digest, subpath, prompt, addr_fmt=AF_CLASSIC, pk=None):
else:
# if private key is provided, derivation subpath is ignored
# and given private key is used for signing.
- node = ngu.hdnode.HDNode().from_chaincode_privkey(bytes(32), pk)
+ node = node_from_privkey(pk)
dis.progress_sofar(50, 100)
addr = ch.address(node, addr_fmt)
diff --git a/shared/nfc.py b/shared/nfc.py
index 53f509b..c66c654 100644
--- a/shared/nfc.py
+++ b/shared/nfc.py
@@ -763,6 +763,11 @@ class NFCHandler:
f = lambda x: x.decode()
return await self._nfc_reader(f, 'Unable to find BIP-322 message.')
+ async def read_wif(self):
+ # only compressed WIFs allowed
+ f = lambda x: x.decode() if len(x) >= 51 else None
+ return await self._nfc_reader(f, 'Unable to find WIF key(s).')
+
async def _nfc_reader(self, func, fail_msg):
data = await self.start_nfc_rx()
if not data: return
diff --git a/shared/nvstore.py b/shared/nvstore.py
index 0dcabaa..7c581ba 100644
--- a/shared/nvstore.py
+++ b/shared/nvstore.py
@@ -69,6 +69,7 @@ from utils import call_later_ms
# ktrx = (privkey) Key teleport Rx has been started, this will be our keypair
# sssp = (complex) If present, a (single signer) spending-policy is defined (maybe disabled)
# lfr = (string) If present, the reason why Spending Policy blocked last transaction
+# wifs = (list) List of tuples (public/private key)
# Stored w/ key=00 for access before login
# _skip_pin = hard code a PIN value (dangerous, only for debug)
diff --git a/shared/ownership.py b/shared/ownership.py
index 663585a..7c3efb2 100644
--- a/shared/ownership.py
+++ b/shared/ownership.py
@@ -6,8 +6,10 @@ import os, chains, ngu, struct, version
from glob import settings
from ucollections import namedtuple
from ubinascii import hexlify as b2a_hex
+from ubinascii import unhexlify as a2b_hex
from exceptions import UnknownAddressExplained
from utils import problem_file_line, show_single_address
+from public_constants import AFC_SCRIPT, AF_P2WPKH_P2SH, AF_P2SH, AF_P2WSH_P2SH, AF_P2TR, AF_P2WSH
# Track many addresses, but in compressed form
# - map from random Bech32/Base58 payment address to (wallet) + keypath
@@ -212,20 +214,13 @@ class OwnershipCache:
return doit
@classmethod
- def filter(cls, addr, args):
+ def filter(cls, addr_fmt, args):
# Filter possible candidates!
# - if you start w/ testnet, we'll follow that
from multisig import MultisigWallet
- from public_constants import AFC_SCRIPT, AF_P2WPKH_P2SH, AF_P2SH, AF_P2WSH_P2SH
- ch = chains.current_chain()
args = args or {}
- addr_fmt = ch.possible_address_fmt(addr)
- if not addr_fmt:
- # might be valid address over on testnet vs mainnet
- raise UnknownAddressExplained('That address is not valid on ' + ch.name)
-
# user has specified specific (named) wallet
named_wal = args.get("wallet", None)
if named_wal:
@@ -309,7 +304,13 @@ class OwnershipCache:
dis.fullscreen("Wait...")
- matches = OWNERSHIP.filter(addr, args)
+ ch = chains.current_chain()
+ addr_fmt = ch.possible_address_fmt(addr)
+ if not addr_fmt:
+ # might be valid address over on testnet vs mainnet
+ raise UnknownAddressExplained('That address is not valid on ' + ch.name)
+
+ matches = OWNERSHIP.filter(addr_fmt, args)
# build cache files for both external & internal chain
cachefs = []
@@ -336,9 +337,17 @@ class OwnershipCache:
# first arg from_cache=False
return False, wallet, subpath
- else:
- raise UnknownAddressExplained('Searched %d candidate addresses in %d wallet(s)'
- ' without finding a match.' % (c, len(matches)))
+ # nothing found among singlesig & registered multisig wallets
+ # check WIF store (single sig only)
+ if addr_fmt not in [AF_P2TR, AF_P2WSH]:
+ from wif import iter_wif_store_addresses
+ target_af = AF_P2WPKH_P2SH if addr_fmt == AF_P2SH else addr_fmt
+ for i, store_addr in iter_wif_store_addresses(ch, target_af):
+ if store_addr == addr:
+ return False, "wif", i+1
+
+ raise UnknownAddressExplained('Searched %d candidate addresses in %d wallet(s)'
+ ' without finding a match.' % (c, len(matches)))
@classmethod
async def search_ux(cls, addr, args):
@@ -351,23 +360,24 @@ class OwnershipCache:
try:
_, wallet, subpath = cls.search(addr, args)
is_ms = isinstance(wallet, MultisigWallet)
- sp = wallet.render_path(*subpath)
-
msg = show_single_address(addr)
- msg += '\n\nFound in wallet:\n ' + wallet.name
- msg += '\nDerivation path:\n ' + sp
- if is_ms:
- esc = ""
+ esc = ""
+ if wallet == "wif":
+ msg += '\n\nFound in WIF store at index %d' % subpath
else:
- esc = "0"
- msg += "\n\nPress (0) to sign message with this key."
+ sp = wallet.render_path(*subpath)
+ msg += '\n\nFound in wallet:\n ' + wallet.name
+ msg += '\nDerivation path:\n ' + sp
+ if not is_ms:
+ esc = "0"
+ msg += "\n\nPress (0) to sign message with this key."
title = "Verified"
if version.has_qwerty:
esc += KEY_QR
title += " Address"
else:
- msg += ' (1) for address QR'
+ msg += ' Press (1) for address QR.'
esc += '1'
title += "!"
diff --git a/shared/psbt.py b/shared/psbt.py
index 7c2fb58..c65557a 100644
--- a/shared/psbt.py
+++ b/shared/psbt.py
@@ -6,7 +6,7 @@ import stash, gc, history, sys, ngu, ckcc, chains
from ustruct import unpack_from, unpack, pack
from ubinascii import hexlify as b2a_hex
from utils import xfp2str, B2A, keypath_to_str
-from utils import seconds2human_readable, datetime_from_timestamp, datetime_to_str
+from utils import seconds2human_readable, datetime_from_timestamp, datetime_to_str, node_from_privkey
from chains import NLOCK_IS_TIME
from uhashlib import sha256
from uio import BytesIO
@@ -20,6 +20,7 @@ from serializations import SIGHASH_ALL, SIGHASH_SINGLE, SIGHASH_NONE, SIGHASH_AN
from serializations import ALL_SIGHASH_FLAGS
from opcodes import OP_CHECKMULTISIG, OP_RETURN
from glob import settings
+from wif import init_wif_store
from public_constants import (
PSBT_GLOBAL_UNSIGNED_TX, PSBT_GLOBAL_XPUB, PSBT_IN_NON_WITNESS_UTXO, PSBT_IN_WITNESS_UTXO,
@@ -259,7 +260,7 @@ class psbtProxy:
self.fd.seek(pos)
return self.fd.read(ll)
- def parse_subpaths(self, my_xfp, warnings):
+ def parse_subpaths(self, my_xfp, parent):
# Reformat self.subpaths into a more useful form for us; return # of them
# that are ours (and track that as self.num_our_keys)
# - works in-place, on self.subpaths
@@ -295,8 +296,8 @@ class psbtProxy:
# XFP is unknown because PSBT built from derived XPUB only. Also privacy.
if here[0] == 0:
here[0] = my_xfp
- if not any(True for k,_ in warnings if 'XFP' in k):
- warnings.append(('Zero XFP',
+ if not any(True for k,_ in parent.warnings if 'XFP' in k):
+ parent.warnings.append(('Zero XFP',
'Assuming XFP of zero should be replaced by correct XFP'))
# update in place
@@ -304,6 +305,8 @@ class psbtProxy:
if here[0] == my_xfp:
num_ours += 1
+ elif pk in parent.wif_store:
+ num_ours += 1
else:
# Address that isn't based on my seed; might be another leg in a p2sh,
# or an input we're not supposed to be able to sign... and that's okay.
@@ -402,7 +405,7 @@ class psbtOutputProxy(psbtProxy):
# - full key derivation and validation is done during signing, and critical.
# - we raise fraud alarms, since these are not innocent errors
#
- num_ours = self.parse_subpaths(my_xfp, parent.warnings)
+ num_ours = self.parse_subpaths(my_xfp, parent)
# - must match expected address for this output, coming from unsigned txn
af, addr_or_pubkey, is_segwit = txo.get_address()
@@ -574,7 +577,7 @@ class psbtInputProxy(psbtProxy):
'unknown', 'utxo', 'witness_utxo', 'sighash', 'redeem_script', 'witness_script',
'fully_signed', 'is_segwit', 'is_multisig', 'is_p2sh', 'num_our_keys',
'required_key', 'scriptSig', 'amount', 'scriptCode', 'previous_txid',
- 'prevout_idx', 'sequence', 'req_time_locktime', 'req_height_locktime', 'addr_fmt'
+ 'prevout_idx', 'sequence', 'req_time_locktime', 'req_height_locktime', 'addr_fmt',
)
def __init__(self, fd, idx):
@@ -655,7 +658,7 @@ class psbtInputProxy(psbtProxy):
# require path for each addr, check some are ours
# rework the pubkey => subpath mapping
- self.parse_subpaths(my_xfp, parent.warnings)
+ self.parse_subpaths(my_xfp, parent)
if self.part_sigs:
# How complete is the set of signatures so far?
@@ -760,7 +763,7 @@ class psbtInputProxy(psbtProxy):
self.amount = utxo.nValue
self.addr_fmt, addr_or_pubkey, addr_is_segwit = utxo.get_address()
- if not self.subpaths or self.fully_signed:
+ if not self.subpaths or self.fully_signed or (not self.num_our_keys):
# without xfp+path we will not be able to sign this input
# - okay if fully signed
# - okay if payjoin or other multi-signer (not multisig) txn
@@ -816,6 +819,10 @@ class psbtInputProxy(psbtProxy):
# slight chance of dup xfps, so handle
which_key.add(pubkey)
+ elif pubkey in psbt.wif_store:
+ # maybe sset some input value
+ which_key.add(pubkey)
+
if not addr_is_segwit and \
len(redeem_script) == 22 and \
redeem_script[0] == 0 and redeem_script[1] == 20:
@@ -1022,6 +1029,7 @@ class psbtObject(psbtProxy):
self.xpubs = [] # tuples(xfp_path, xpub)
self.my_xfp = settings.get('xfp', 0)
+ self.wif_store = init_wif_store()
# details that we discover as we go
self.inputs = None
@@ -1760,13 +1768,13 @@ class psbtObject(psbtProxy):
# Important: parse incoming UTXO to build total input value
foreign = []
total_in = 0
-
+ from_wif_store = []
prevouts = set()
for i, txi in self.input_iter():
# check for duplicate inputs
k = (txi.prevout.hash, txi.prevout.n)
- if k in prevouts:
+ if k in prevouts:
raise FatalPSBTIssue("Duplicate inputs")
if len(prevouts) < 100:
@@ -1796,6 +1804,14 @@ class psbtObject(psbtProxy):
# - also validates redeem_script when present
# - also finds appropriate multisig wallet to be used
inp.determine_my_signing_key(i, utxo, self.my_xfp, self, cosign_xfp)
+ if inp.required_key and self.wif_store:
+ is_in = False
+ for pk in inp.required_key if isinstance(inp.required_key, set) else [inp.required_key]:
+ if pk in self.wif_store:
+ is_in = True
+
+ if is_in:
+ from_wif_store.append(i)
# iff to UTXO is segwit, then check it's value, and also
# capture that value, since it's supposed to be immutable
@@ -1888,6 +1904,10 @@ class psbtObject(psbtProxy):
'Some input(s) provided were already completely signed by other parties: ' +
seq_to_str(self.presigned_inputs)))
+ if from_wif_store:
+ self.warnings.append(("WIF Store", "Some input(s) use key from the WIF store: " +
+ seq_to_str(from_wif_store)))
+
if MultisigWallet.disable_checks:
self.warnings.append(('Danger', 'Some multisig checks are disabled.'))
@@ -2117,7 +2137,11 @@ class psbtObject(psbtProxy):
# need to consider a set of possible keys, since xfp may not be unique
for which_key in inp.required_key:
# get node required
- node = self.check_pubkey_at_path(sv, inp.subpaths[which_key], which_key)
+ if which_key in self.wif_store:
+ node = node_from_privkey(self.wif_store[which_key])
+ else:
+ node = self.check_pubkey_at_path(sv, inp.subpaths[which_key], which_key)
+
if node:
break
else:
@@ -2126,27 +2150,27 @@ class psbtObject(psbtProxy):
else:
# single pubkey <=> single key
which_key = inp.required_key
-
assert not inp.added_sigs, "already done??"
assert which_key in inp.subpaths, 'unk key'
- if inp.subpaths[which_key][0] != my_xfp:
- # we don't have the key for this subkey
- # (redundant, required_key wouldn't be set)
- continue
- # get node required
- skp = keypath_to_str(inp.subpaths[which_key])
- node = sv.derive_path(skp, register=False)
+ if which_key in self.wif_store:
+ node = node_from_privkey(self.wif_store[which_key])
+ else:
+ # get node required
+ skp = keypath_to_str(inp.subpaths[which_key])
+ node = sv.derive_path(skp, register=False)
+
+ # expensive test, but works... and important
+ pu = node.pubkey()
+
+ assert pu == which_key, \
+ "Path (%s) led to wrong pubkey for input#%d"%(skp, in_idx)
- # expensive test, but works... and important
- pu = node.pubkey()
- assert pu == which_key, \
- "Path (%s) led to wrong pubkey for input#%d"%(skp, in_idx)
+ # track wallet usage
- # track wallet usage
- OWNERSHIP.note_subpath_used(inp.subpaths[which_key])
+ OWNERSHIP.note_subpath_used(inp.subpaths[which_key])
if not inp.is_segwit:
# Hash by serializing/blanking various subparts of the transaction
diff --git a/shared/stash.py b/shared/stash.py
index d7f4ffe..9f28e2c 100644
--- a/shared/stash.py
+++ b/shared/stash.py
@@ -12,7 +12,7 @@
#
import ngu, uctypes, gc, bip39, utime
from uhashlib import sha256
-from utils import swab32, call_later_ms, B2A
+from utils import swab32, call_later_ms, B2A, node_from_privkey
SEED_LEN_OPTS = [12, 18, 24]
@@ -104,7 +104,7 @@ class SecretStash:
ch, pk = secret[1:33], secret[33:65]
assert not _bip39pw
- hd.from_chaincode_privkey(ch, pk)
+ hd = node_from_privkey(pk, ch)
return 'xprv', ch+pk, hd
elif marker & 0x80:
@@ -403,8 +403,7 @@ class SensitiveValues:
self.register(cc)
self.register(pk)
- rv = ngu.hdnode.HDNode()
- rv.from_chaincode_privkey(cc, pk)
+ rv = node_from_privkey(pk, cc)
self.register(rv)
return rv, p
diff --git a/shared/trick_pins.py b/shared/trick_pins.py
index f305984..0476962 100644
--- a/shared/trick_pins.py
+++ b/shared/trick_pins.py
@@ -12,6 +12,7 @@ from menu import MenuSystem, MenuItem
from ux import ux_show_story, ux_confirm, ux_dramatic_pause, ux_enter_number, the_ux
from stash import SecretStash
from drv_entro import bip85_derive
+from utils import node_from_privkey
# see from mk4-bootloader/se2.h
NUM_TRICKS = const(14)
@@ -897,9 +898,8 @@ Wallet is XPRV-based and derived from a fixed path.''' % pin
assert s.tc_flags == flags
if flags & TC_XPRV_WALLET:
- node = ngu.hdnode.HDNode()
ch, pk = s.xdata[0:32], s.xdata[32:64]
- node.from_chaincode_privkey(ch, pk)
+ node = node_from_privkey(pk, ch)
title, msg, *_ = render_master_secrets('xprv', None, node)
elif flags & TC_WORD_WALLET:
diff --git a/shared/utils.py b/shared/utils.py
index 73d7e3a..93d6029 100644
--- a/shared/utils.py
+++ b/shared/utils.py
@@ -788,4 +788,11 @@ def extract_cosigner(data, af_str):
# emulate coldcard export xpubs
return {"xfp": xfp, af_str: ek, key_deriv: deriv}
+
+def node_from_privkey(privkey, chain_code=bytes(32)):
+ return ngu.hdnode.HDNode().from_chaincode_privkey(chain_code, privkey)
+
+def node_from_pubkey(pubkey, chain_code=bytes(32)):
+ return ngu.hdnode.HDNode().from_chaincode_pubkey(chain_code, pubkey)
+
# EOF
diff --git a/shared/wif.py b/shared/wif.py
new file mode 100644
index 0000000..39eeba5
--- /dev/null
+++ b/shared/wif.py
@@ -0,0 +1,315 @@
+# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
+#
+import chains, ngu, version
+from ubinascii import hexlify as b2a_hex
+from ubinascii import unhexlify as a2b_hex
+from ux import ux_show_story, ux_confirm, the_ux, import_export_prompt, ux_input_text, show_qr_code
+from menu import MenuSystem, MenuItem
+from utils import problem_file_line, show_single_address, node_from_pubkey
+from files import CardSlot
+from glob import settings
+from charcodes import KEY_QR, KEY_NFC, KEY_CANCEL
+from public_constants import AF_P2WPKH
+from msgsign import msg_signing_done
+
+
+def decode_wif(wif):
+ raw = ngu.codecs.b58_decode(wif)
+ assert raw[0] in (0xef, 0x80)
+ testnet = True if raw[0] == 0xef else False
+ assert len(raw) in (33, 34)
+ compressed = False
+ if len(raw) == 34: # compressed pubkey
+ assert raw[33] == 0x01
+ compressed = True
+ sk = raw[1:33]
+ kp = ngu.secp256k1.keypair(sk) # catches wrong private keys
+ return kp, testnet, compressed
+
+
+def iter_wif_store_addresses(chain, addr_fmt):
+ # nothing found among singlesig & registered multisig wallets
+ # check WIF store
+ wifs = settings.get("wifs", [])
+ if not wifs: return
+
+ for i, (pk, sk) in enumerate(wifs):
+ node = node_from_pubkey(a2b_hex(pk))
+ yield i, chain.address(node, addr_fmt)
+
+
+class WIFStore(MenuSystem):
+ MAX_ITEMS = 30
+
+ def __init__(self):
+ items = self.construct()
+ super().__init__(items)
+
+ @classmethod
+ async def make_menu(cls, *a):
+ if not settings.get("wifs", None):
+ intro = ("Individual private keys, encoded as WIF (Wallet Import Format) keys"
+ " can be imported and used for signing. Any PSBT that uses a WIF stored here"
+ " will be signed as normal, but warning is shown."
+ " Remove all imported keys to disable WIF store signing")
+
+ ch = await ux_show_story(intro, title="WIF Store")
+ if ch != 'y': return
+ return cls()
+
+ def update_contents(self):
+ tmp = self.construct()
+ self.replace_items(tmp)
+
+ def construct(self):
+ from glob import dis
+ from seed import not_hobbled_mode
+
+ dis.fullscreen("Wait...")
+
+ ch = chains.current_chain()
+ wifs = settings.get('wifs', [])
+
+ items = []
+
+ if len(wifs) < self.MAX_ITEMS:
+ items.append(MenuItem('Import WIF', f=self.import_wif, predicate=not_hobbled_mode))
+
+ a_items = []
+ export_all = []
+ for i, (pk, sk) in enumerate(wifs):
+ wif = ngu.codecs.b58_encode(ch.b58_privkey + a2b_hex(sk) + b'\x01')
+ export_all.append(wif)
+
+ submenu = [
+ MenuItem("Detail", f=self.detail, arg=(wif,pk,sk)),
+ MenuItem("Addresses", f=self.show_addr_step1, arg=pk),
+ MenuItem("Sign MSG", f=self.sign_msg_step1, arg=sk),
+ MenuItem('Delete', f=self.delete, arg=(i, pk), predicate=not_hobbled_mode),
+ ]
+
+ # cannot use truncate_address here, as it does nto fit on Mk4 (because padded numbering)
+ clen = 12 if version.has_qwerty else 5
+ a_items.append(MenuItem("%2d: %s" % (i+1, wif[0:clen] + '⋯' + wif[-clen:]),
+ menu=MenuSystem(submenu)))
+
+ if a_items:
+ items += a_items
+ if len(a_items) > 1:
+ items.append(MenuItem("Export All", f=self.export_all, arg=export_all))
+ items.append(MenuItem("Clear All", f=self.clear_all, predicate=not_hobbled_mode))
+ else:
+ items.append(MenuItem("(none yet)"))
+
+ return items
+
+ async def detail(self, a, b, item):
+ wif, pk, sk = item.arg
+ msg = "%s\n\nPrivkey:\n%s\n\nPubkey:\n%s" % (wif, sk, pk)
+
+ from export import export_contents
+ title = "WIF"
+ await export_contents(title, wif, "wif.txt", None, None,
+ force_prompt=True, intro=msg, ux_title=title)
+
+ async def show_addr_step1(self, a, b, item):
+ pubkey = a2b_hex(item.arg)
+ rv = [
+ MenuItem(chains.addr_fmt_label(af), f=self.show_addr_step2, arg=(pubkey, af))
+ for af in chains.SINGLESIG_AF
+ ]
+ the_ux.push(MenuSystem(rv))
+
+ async def show_addr_step2(self, a, b, item):
+ from glob import NFC
+ pubkey, af = item.arg
+ node = node_from_pubkey(pubkey)
+ addr = chains.current_chain().address(node, af)
+ msg = show_single_address(addr) + "\n\n"
+
+ escape = ""
+ # Q only hint keys
+ if not version.has_qwerty:
+ msg += "Press (1) to show address QR code."
+ escape += "1"
+ if NFC:
+ msg += "(3) to share via NFC."
+ escape += "3"
+
+ title = chains.addr_fmt_label(af) if version.has_qwerty else None
+ while True:
+ ch = await ux_show_story(msg, title=title, escape=escape,
+ hint_icons=KEY_QR+(KEY_NFC if NFC else ''))
+ if ch == "x": return
+ if ch in "1"+KEY_QR:
+ await show_qr_code(addr, is_alnum=af == AF_P2WPKH)
+
+ elif NFC and (ch in "3"+KEY_NFC):
+ await NFC.share_text(addr)
+
+ async def sign_msg_step1(self, a, b, item):
+ privkey = a2b_hex(item.arg)
+ rv = [
+ MenuItem(chains.addr_fmt_label(af), f=self.sign_msg_step2, arg=(privkey, af))
+ for af in chains.SINGLESIG_AF
+ ]
+ the_ux.push(MenuSystem(rv))
+
+ async def sign_msg_step2(self, a, b, item):
+ from glob import NFC
+ from actions import file_picker
+ from auth import approve_msg_sign
+
+ ch = await import_export_prompt("message", is_import=True, force_prompt=True,
+ key0="to input message manually",
+ no_qr=not version.has_qwerty)
+ if ch == KEY_CANCEL:
+ return
+ elif ch == "0":
+ msg = await ux_input_text("", confirm_exit=False)
+ elif ch == KEY_NFC:
+ msg = await NFC.read_bip322_msg()
+ elif ch == KEY_QR:
+ from ux_q1 import QRScannerInteraction
+ msg = await QRScannerInteraction().scan_text('Scan message from a QR code')
+ else:
+ fn = await file_picker(suffix='.txt')
+ if not fn: return
+
+ with CardSlot(readonly=True, **ch) as card:
+ with open(fn, 'rt') as fd:
+ msg = fd.read()
+
+ if not msg: return
+ privkey, af = item.arg
+
+ await approve_msg_sign(msg, "", af, privkey=privkey, approved_cb=msg_signing_done)
+
+
+ async def delete(self, a, b, item):
+ # no confirm, stakes are low
+ if not await ux_confirm("Delete WIF key?"):
+ return
+
+ idx, pubkey = item.arg
+ wifs = settings.get('wifs', {})
+ if not wifs: return
+
+ try:
+ item = wifs[idx]
+ assert item[0] == pubkey
+ del wifs[idx]
+ settings.set('wifs', wifs)
+ settings.save()
+ except IndexError: pass
+
+ the_ux.pop() # pop submenu
+ self.update_contents()
+
+ async def clear_all(self, *a):
+ if await ux_confirm("Remove all saved WIF keys?", confirm_key='4'):
+ settings.remove_key("wifs")
+ settings.save()
+ self.update_contents()
+
+ async def export_all(self, a, b, item):
+ wifs = item.arg
+ from export import export_contents
+ title = "WIF Store"
+ await export_contents(title, "\n".join(wifs), "wif_store.txt",
+ None, None, force_prompt=True, ux_title=title)
+
+
+ async def import_wif(self, *a):
+ from glob import NFC, dis
+ from actions import file_picker
+
+ label = "WIF private key"
+ ch = await import_export_prompt(label, is_import=True, key0="to input WIF manually")
+
+ if ch == KEY_CANCEL:
+ return
+ elif ch == KEY_NFC:
+ got = await NFC.read_wif()
+
+ elif ch == KEY_QR:
+ from ux_q1 import QRScannerInteraction
+ got = await QRScannerInteraction().scan_text(label)
+
+ elif ch == "0":
+ got = await ux_input_text("", confirm_exit=False, max_len=52) # compressed WIF key str length is 52
+
+ else:
+ # pick a likely-looking file: just looking at size and extension
+ fn = await file_picker(suffix='.txt', min_size=51, max_size=2000,
+ none_msg="Must contain WIF(s)", **ch)
+
+ if not fn: return
+
+ with CardSlot(readonly=True, **ch) as card:
+ with open(fn, 'rt') as fd:
+ got = fd.read().strip()
+
+ if not got:
+ return
+
+ dis.fullscreen("Wait...")
+
+ # allow both newlines and commas as separators
+ if "\n" in got:
+ wifs = got.split("\n")
+ elif "," in got:
+ wifs = got.split(",")
+ else:
+ # just one wif
+ wifs = [got]
+
+ saved = settings.get("wifs", [])
+ len_saved = len(saved)
+
+ if (len_saved + len(wifs)) > self.MAX_ITEMS:
+ await ux_show_story("Max %d items allowed in WIF Store.\n\nAttempted to import %d keys,"
+ " while remaining WIF store capacity is only %d. Please, make room"
+ " first." % (self.MAX_ITEMS, len(wifs), self.MAX_ITEMS - len_saved),
+ title="Failure")
+ return
+
+ try:
+ for wif in wifs:
+ try:
+ wif = wif.strip()
+ kp, testnet, compressed = decode_wif(wif)
+ except Exception:
+ raise ValueError("wif decode")
+
+ assert compressed, "compressed only"
+ assert testnet == (chains.current_chain().ctype != "BTC"), "chain"
+
+ sk = b2a_hex(kp.privkey()).decode()
+ pk = b2a_hex(kp.pubkey().to_bytes()).decode()
+
+ item = (pk, sk)
+
+ if item not in saved: # ignore duplicates
+ saved.append(item)
+
+ if len_saved < len(saved):
+ settings.set('wifs', saved)
+ settings.save()
+ self.update_contents()
+
+ except Exception as e:
+ await ux_show_story('Failed to import WIF.\n\n%s\n%s' % (e, problem_file_line(e)),
+ title="Failure")
+
+
+def init_wif_store():
+ # stored as hex strings, need load to bytes
+ wifs = settings.get('wifs', [])
+ if not wifs: return {}
+ res = {}
+ for pk, sk in wifs:
+ res[a2b_hex(pk)] = a2b_hex(sk)
+ return res
+
+# EOF
\ No newline at end of file
diff --git a/testing/conftest.py b/testing/conftest.py
index f37222e..b7f6ba5 100644
--- a/testing/conftest.py
+++ b/testing/conftest.py
@@ -16,6 +16,7 @@ from charcodes import *
from core_fixtures import _need_keypress, _sim_exec, _cap_story, _cap_menu, _cap_screen, _sim_eval
from core_fixtures import _press_select, _pick_menu_item, _enter_complex, _dev_hw_label
from txn import render_address
+from bbqr import split_qrs
# lock down randomness
@@ -2907,6 +2908,113 @@ def set_deltamode(sim_exec):
doit(False)
+@pytest.fixture
+def import_wif_to_store(goto_home, pick_menu_item, cap_story, press_select, cap_menu,
+ microsd_path, virtdisk_path, is_q1, scan_a_qr, need_keypress,
+ garbage_collector, press_nfc, nfc_write_text, enter_complex):
+ def doit(wif_lst, way="sd", sep="\n", early_exit=False):
+ home = True
+ try:
+ m = cap_menu()
+ if m[0] == "Import WIF":
+ home = False
+ except: pass
+
+ if home:
+ goto_home()
+ pick_menu_item("Advanced/Tools")
+ pick_menu_item("WIF Store")
+ time.sleep(.1)
+ title, story = cap_story()
+ if title == "WIF Store":
+ press_select()
+
+ menu = cap_menu()
+ assert menu[0] == "Import WIF"
+ num_items = sum(1 for i in menu if "⋯" in i)
+ pick_menu_item("Import WIF")
+ time.sleep(.1)
+ title, story = cap_story()
+
+ conts = sep.join(wif_lst)
+
+ if way == "nfc":
+ if f"press {KEY_NFC if is_q1 else '(3)'} to import via NFC" not in story:
+ pytest.xfail("NFC disabled")
+ else:
+ press_nfc()
+ time.sleep(0.2)
+ nfc_write_text(conts)
+ time.sleep(0.3)
+ elif way == "qr":
+ if not is_q1:
+ raise pytest.xfail("Mk4 no QR")
+
+ assert f"{KEY_QR} to scan QR code" in story
+ need_keypress(KEY_QR)
+
+ if sep == "\n" or len(wif_lst) > 3:
+ # use BBQr
+ actual_vers, parts = split_qrs(conts, 'U', max_version=10)
+ random.shuffle(parts)
+
+ for p in parts:
+ scan_a_qr(p)
+ time.sleep(2.0 / len(parts))
+ else:
+ scan_a_qr(conts)
+ time.sleep(1)
+ elif way == "input":
+ assert "(0) to input WIF manually" in story
+ need_keypress("0")
+ enter_complex(conts, apply=False, b39pass=False)
+ else:
+ if way == "sd":
+ assert "Press (1) to import WIF private key from SD Card" in story
+ to_press = "1"
+ path_f = microsd_path
+ else:
+ if "press (2) to import from Virtual Disk" not in story:
+ raise pytest.xfail("Vdisk disabled")
+ else:
+ to_press = "2"
+ path_f = virtdisk_path
+
+ fname = "wif.txt"
+ fpath = path_f(fname)
+ with open(fpath, "w") as f:
+ f.write(conts)
+
+ need_keypress(to_press)
+
+ try:
+ pick_menu_item(fname)
+ except: pass
+
+ if early_exit:
+ return
+
+ time.sleep(.2)
+ new_menu = cap_menu()
+ # assert new_menu[0] == "Import WIF"
+ new_num_items = sum(1 for i in new_menu if "⋯" in i)
+ assert new_num_items == (num_items + len(wif_lst))
+
+ if "Clear All" in new_menu[-1]:
+ new_menu = new_menu[:-1]
+
+ if "Export All" in new_menu[-1]:
+ new_menu = new_menu[:-1]
+
+ len_added = len(wif_lst)
+ for i, mi in enumerate(new_menu[-len_added:]):
+ left, right = mi.split("⋯")
+ left = left.split(" ")[-1] # ignore numbering
+
+ assert wif_lst[i].startswith(left)
+ assert wif_lst[i].endswith(right)
+
+ return doit
# useful fixtures
from test_backup import backup_system
diff --git a/testing/test_hobble.py b/testing/test_hobble.py
index f929c74..cdf2a22 100644
--- a/testing/test_hobble.py
+++ b/testing/test_hobble.py
@@ -18,6 +18,7 @@
#
import pytest, time, os, pdb
from bip32 import BIP32Node
+from base58 import encode_base58_checksum
from constants import simulator_fixed_words, simulator_fixed_xprv
from test_ephemeral import SEEDVAULT_TEST_DATA, WORDLISTS
from test_ephemeral import confirm_tmp_seed, verify_ephemeral_secret_ui
@@ -111,6 +112,7 @@ def test_menu_contents(set_hobble, pick_menu_item, cap_menu, en_okeys, en_notes,
if en_okeys:
adv_expect.add('Temporary Seed')
+ adv_expect.add('WIF Store')
m = cap_menu()
assert set(m) == adv_expect, "Adv menu wrong"
@@ -394,6 +396,38 @@ def test_h_tempseeds(mode, set_hobble, pick_menu_item, cap_menu, settings_set, i
press_select()
+@pytest.mark.parametrize('en_okeys', [ True, False])
+def test_h_wif_store(en_okeys, set_hobble, settings_remove, import_wif_to_store, goto_home,
+ cap_menu, pick_menu_item):
+
+ settings_remove("wifs")
+
+ wif_list = [
+ encode_base58_checksum(bytes([239]) + os.urandom(32) + b'\x01')
+ for _ in range(3)
+ ]
+
+ import_wif_to_store(wif_list)
+ goto_home()
+
+ set_hobble(True, {'okeys'} if en_okeys else {})
+ pick_menu_item("Advanced/Tools")
+
+ if en_okeys:
+ pick_menu_item("WIF Store")
+ time.sleep(.1)
+ menu = cap_menu()
+ # check it is read-only
+ assert "Import WIF" not in menu
+ assert "Clear All" not in menu
+ pick_menu_item(menu[0])
+ time.sleep(.1)
+ menu = cap_menu()
+ assert "Delete" not in menu
+ else:
+ assert "WIF Store" not in cap_menu()
+
+
@pytest.mark.parametrize('en_okeys', [ True, False])
def test_h_usbcmds(en_okeys, set_hobble, dev):
# test various usb commands are blocked during hobble
diff --git a/testing/test_msg.py b/testing/test_msg.py
index 03b7aee..4348521 100644
--- a/testing/test_msg.py
+++ b/testing/test_msg.py
@@ -6,6 +6,7 @@ import pytest, time, os, itertools, hashlib, json
from bip32 import BIP32Node
from msg import verify_message, RFC_SIGNATURE_TEMPLATE, sign_message, parse_signed_message
from base64 import b64encode, b64decode
+from base58 import encode_base58_checksum
from ckcc_protocol.protocol import CCProtocolPacker, CCProtoError, CCUserRefused
from ckcc_protocol.constants import *
from constants import addr_fmt_names, msg_sign_unmap_addr_fmt
@@ -1040,4 +1041,65 @@ def test_verify_scanned_signed_msg(msg, scan_a_qr, need_keypress, goto_home, cap
assert "Good signature by address" in story
assert addr == addr_from_display_format(story.split("\n")[-1])
+
+@pytest.mark.parametrize("way,af", [
+ ("sd", "P2SH-Segwit"),
+ ("input", "Segwit P2WPKH"),
+ ("nfc", "Classic P2PKH")
+])
+def test_sign_msg_with_wif_store_key(way, af, settings_remove, import_wif_to_store, cap_menu,
+ pick_menu_item, cap_story, need_keypress, press_nfc,
+ enter_complex, garbage_collector, microsd_path, nfc_write_text,
+ verify_msg_sign_story, msg_sign_export, press_select, goto_home):
+ settings_remove("wifs")
+ msg = "Coinkite"
+
+ n = BIP32Node.from_master_secret(os.urandom(32))
+ privkey = n.node.private_key
+ import_wif_to_store([encode_base58_checksum(bytes([239]) + bytes(privkey) + b'\x01')])
+
+ menu = cap_menu()
+ assert len(menu) == 2
+ pick_menu_item(menu[1])
+ pick_menu_item("Sign MSG")
+ pick_menu_item(af)
+
+ if way == "input":
+ need_keypress("0")
+ enter_complex(msg, apply=False, b39pass=False)
+
+ elif way == "sd":
+ name = "msg_to_sign.txt"
+ pth = microsd_path(name)
+ with open(pth, "w") as f:
+ f.write(msg)
+
+ need_keypress("1")
+ pick_menu_item(name)
+
+ elif way == "nfc":
+ press_nfc()
+ time.sleep(0.2)
+ nfc_write_text(msg)
+ time.sleep(0.3)
+
+ else:
+ raise NotImplementedError
+
+ time.sleep(.1)
+ title, story = cap_story()
+ addr_fmt = {"P2SH-Segwit": "p2sh-p2wpkh",
+ "Segwit P2WPKH": "p2wpkh",
+ "Classic P2PKH": "p2pkh"}[af]
+
+ target_addr = n.address(addr_fmt=addr_fmt)
+ verify_msg_sign_story(story, msg, "m", addr=target_addr)
+ press_select()
+ res = msg_sign_export(way if way != "input" else "sd")
+ assert target_addr in res
+ pmsg, addr, sig = parse_signed_message(res)
+ assert pmsg == msg
+ assert verify_message(addr, sig, msg) is True
+ goto_home()
+
# EOF
diff --git a/testing/test_multisig.py b/testing/test_multisig.py
index 8bca38b..d27ba74 100644
--- a/testing/test_multisig.py
+++ b/testing/test_multisig.py
@@ -4275,4 +4275,68 @@ def test_txin_explorer_our_sig(dev, fake_ms_txn, start_sign, settings_set, clear
start_sign(psbt)
txin_explorer(num_ins, [(af, inp_amount, 0, "XTN", (M,N), None, None, False, [my_xfp])])
+
+@pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh", "p2sh"])
+def test_wif_store(addr_fmt, dev, fake_ms_txn, start_sign, settings_set, clear_ms,
+ cap_story, pytestconfig, import_ms_wallet, end_sign, settings_remove):
+ # TODO This test MUST be run with --psbt2 flag on and off
+ clear_ms()
+ settings_remove("wifs")
+ M, N = 3, 5
+
+ if addr_fmt == AF_P2SH:
+ dd = "m/45h"
+ elif addr_fmt == AF_P2WSH:
+ dd = "m/48h/1h/0h/2h"
+ else:
+ dd = "m/48h/1h/0h/1h"
+
+ def path_mapper(idx):
+ kk = str_to_path(dd)
+ return kk + [0,0]
+
+ keys = import_ms_wallet(M, N, name='wif_store', accept=True, netcode="XTN",
+ descriptor=True, addr_fmt=addr_fmt, common=dd)
+
+ psbt = fake_ms_txn(1, 1, M, keys, inp_af=unmap_addr_fmt[addr_fmt],
+ path_mapper=path_mapper, psbt_v2=pytestconfig.getoption('psbt2'))
+
+ # sign with master key first - nothing in WIF store
+ # without warning
+ # one signature from master added
+ start_sign(psbt)
+ title, story = cap_story()
+ assert "warning" not in story
+ signed = end_sign()
+
+ po = BasicPSBT().parse(signed)
+ assert len(po.inputs[0].part_sigs) == 1
+
+ # add privkey from 0th & 1st node to WIF store
+ der_node0 = keys[0][1].subkey_for_path(dd[2:] + "/0/0")
+ sk0 = bytes(der_node0.node.private_key).hex()
+ pk0 = der_node0.node.private_key.K.sec().hex()
+ der_node1 = keys[1][1].subkey_for_path(dd[2:] + "/0/0")
+ sk1 = bytes(der_node1.node.private_key).hex()
+ pk1 = der_node1.node.private_key.K.sec().hex()
+ settings_set("wifs", [(pk0,sk0), (pk1,sk1)])
+
+ # ofe of the private keys will be used for signing
+ # only one as we cannot sign with 2 keys in one sitting
+ start_sign(signed)
+ title, story = cap_story()
+ assert "warning" in story
+ assert "WIF store" in story
+ signed = end_sign()
+ po = BasicPSBT().parse(signed)
+ assert len(po.inputs[0].part_sigs) == 2
+
+ # sign with other key - keys that already have signatures are ignored
+ # that is why we can proceed with this iterative method
+ start_sign(signed, finalize=True)
+ title, story = cap_story()
+ assert "warning" in story
+ assert "WIF store" in story
+ end_sign(finalize=True)
+
# EOF
diff --git a/testing/test_ownership.py b/testing/test_ownership.py
index 830136c..8881d77 100644
--- a/testing/test_ownership.py
+++ b/testing/test_ownership.py
@@ -2,7 +2,7 @@
#
# Address ownership tests.
#
-import pytest, time, io, csv
+import pytest, time, io, csv, os
from txn import fake_address
from base58 import encode_base58_checksum
from bech32 import encode as bech32_encode
@@ -699,4 +699,58 @@ def test_named_wallet_search(wname, valid, method, clear_ms, import_ms_wallet, i
assert "1 wallet(s)" in story
assert 'without finding a match' in story
+
+@pytest.mark.parametrize("addr_fmt", ["p2wpkh", "p2sh-p2wpkh", "p2pkh"])
+@pytest.mark.parametrize("idx", [1, 3])
+def test_wif_store(addr_fmt, idx, is_q1, goto_home, pick_menu_item, scan_a_qr, cap_story, need_keypress,
+ src_root_dir, sim_root_dir, nfc_write, settings_remove, import_wif_to_store,
+ load_shared_mod):
+
+ settings_remove("wifs")
+
+ n = BIP32Node.from_master_secret(os.urandom(32))
+ privkey = n.node.private_key
+ addr = n.address(addr_fmt=addr_fmt)
+ wif = encode_base58_checksum(bytes([239]) + bytes(privkey) + b'\x01')
+ wif1 = encode_base58_checksum(bytes([239]) + os.urandom(32) + b'\x01')
+ wif2 = encode_base58_checksum(bytes([239]) + os.urandom(32) + b'\x01')
+
+ if idx == 1:
+ wif_list = [wif, wif1, wif2]
+ else:
+ wif_list = [wif1, wif2, wif]
+
+ import_wif_to_store(wif_list)
+
+ goto_home()
+
+ if is_q1:
+ pick_menu_item('Scan Any QR Code')
+ scan_a_qr(addr)
+ time.sleep(1)
+
+ title, story = cap_story()
+
+ assert addr == addr_from_display_format(story.split("\n\n")[0])
+ assert '(1) to verify ownership' in story
+ need_keypress('1')
+
+ else:
+ cc_ndef = load_shared_mod('cc_ndef', f'{src_root_dir}/shared/ndef.py')
+ n = cc_ndef.ndefMaker()
+ n.add_text(addr)
+ ccfile = n.bytes()
+
+ pick_menu_item('Advanced/Tools')
+ pick_menu_item('NFC Tools')
+ pick_menu_item('Verify Address')
+ with open(f'{sim_root_dir}/debug/nfc-addr.ndef', 'wb') as f:
+ f.write(ccfile)
+ nfc_write(ccfile)
+
+ time.sleep(1)
+ title, story = cap_story()
+ assert addr == addr_from_display_format(story.split("\n\n")[0])
+ assert f"Found in WIF store at index {idx}" in story
+
# EOF
diff --git a/testing/test_sign.py b/testing/test_sign.py
index f97a270..9a4620e 100644
--- a/testing/test_sign.py
+++ b/testing/test_sign.py
@@ -3674,4 +3674,152 @@ def test_txid_qr(fake_txn, start_sign, cap_story, press_cancel, press_select):
assert "(6) for QR Code of TXID" in story
press_cancel()
+@pytest.mark.parametrize("num_ins", [1, 5])
+@pytest.mark.parametrize("addr_fmt", ["p2pkh", "p2wpkh", "p2sh-p2wpkh"])
+def test_wif_store_signing(num_ins, addr_fmt, fake_txn, goto_home, pick_menu_item, need_keypress,
+ start_sign, end_sign, cap_menu, cap_story, press_cancel, settings_remove,
+ press_select, import_wif_to_store):
+
+ settings_remove("wifs")
+
+ wrap = False
+ if addr_fmt == "p2pkh":
+ sw = False
+ elif addr_fmt == "p2wpkh":
+ sw = True
+ elif addr_fmt == "p2sh-p2wpkh":
+ wrap = True
+ sw = True
+ else:
+ raise ValueError
+
+ node = BIP32Node.from_master_secret(os.urandom(32))
+ psbt = fake_txn(num_ins, 1, segwit_in=sw, wrapped=wrap, master_xpub=node.hwif())
+
+ wifs = []
+ privkeys = []
+ for i in range(num_ins):
+ n = node.subkey_for_path("0/%d" % i)
+ sk = n.node.private_key
+ privkeys.append(sk)
+ wifs.append(n.node.private_key.wif(testnet=True))
+
+ import_wif_to_store(wifs)
+
+ menu = cap_menu()
+ assert menu[0] == "Import WIF"
+
+ start_sign(psbt, finalize=True)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert "warning" in story
+ if num_ins == 1:
+ assert "WIF store: 0" in story
+ else:
+ assert f"WIF store: {', '.join([str(i) for i in range(num_ins)])}" in story
+ end_sign(finalize=True)
+
+
+@pytest.mark.parametrize("der_paths", [True, False])
+@pytest.mark.parametrize("complete", [True, False])
+def test_wif_store_multi(der_paths, complete, fake_txn, start_sign, end_sign, cap_story, settings_set):
+ wifs = []
+
+ hack = None
+ if der_paths:
+ def hack(psbt):
+ new_paths = {}
+ for k, v in psbt.inputs[0].bip32_paths.items():
+ new_paths[k] = b"\x01" * 8 # garbage (do not use zero xfp here)
+
+ psbt.inputs[0].bip32_paths = new_paths
+
+
+ node = BIP32Node.from_master_secret(os.urandom(32))
+ psbt = fake_txn(1, 1, segwit_in=True, master_xpub=node.hwif(), psbt_v2=True, outvals=[1E8*3],
+ psbt_hacker=hack)
+ po = BasicPSBT().parse(psbt)
+ n = node.subkey_for_path("0/0")
+ sk = bytes(n.node.private_key).hex()
+ pk = n.node.private_key.K.sec().hex()
+ wifs.append((pk, sk))
+
+ node = BIP32Node.from_master_secret(os.urandom(32))
+ psbt = fake_txn(1, 1, segwit_in=False, master_xpub=node.hwif(), psbt_v2=True, psbt_hacker=hack)
+ tmp = BasicPSBT().parse(psbt)
+ po.inputs += tmp.inputs
+ po.input_count += 1
+ n = node.subkey_for_path("0/0")
+ sk = bytes(n.node.private_key).hex()
+ pk = n.node.private_key.K.sec().hex()
+ wifs.append((pk, sk))
+
+ node = BIP32Node.from_master_secret(os.urandom(32))
+ psbt = fake_txn(1, 1, segwit_in=True, wrapped=True, master_xpub=node.hwif(), psbt_v2=True,
+ psbt_hacker=hack)
+ tmp = BasicPSBT().parse(psbt)
+ po.inputs += tmp.inputs
+ po.input_count += 1
+ n = node.subkey_for_path("0/0")
+ sk = bytes(n.node.private_key).hex()
+ pk = n.node.private_key.K.sec().hex()
+ wifs.append((pk, sk))
+
+ # pretend we have those imported
+ if not complete:
+ wifs = wifs[:-1]
+
+ settings_set("wifs", wifs)
+
+ start_sign(po.as_bytes(), finalize=complete)
+ title, story = cap_story()
+ assert "warning" in story
+ if complete:
+ assert "WIF store: 0, 1, 2" in story
+ else:
+ assert "WIF store: 0, 1" in story
+ assert "Limited Signing" in story
+
+ end_sign(finalize=complete)
+
+
+def test_wif_store_with_master(fake_txn, start_sign, end_sign, cap_story, settings_set):
+ # signs both master key and keys from WIF store
+ wifs = []
+
+ node = BIP32Node.from_master_secret(os.urandom(32))
+ psbt = fake_txn(1, 1, segwit_in=True, master_xpub=node.hwif(), psbt_v2=True, outvals=[1E8*3])
+ po = BasicPSBT().parse(psbt)
+ n = node.subkey_for_path("0/0")
+ sk = bytes(n.node.private_key).hex()
+ pk = n.node.private_key.K.sec().hex()
+ wifs.append((pk, sk))
+
+ node = BIP32Node.from_master_secret(os.urandom(32))
+ psbt = fake_txn(1, 1, segwit_in=False, master_xpub=node.hwif(), psbt_v2=True)
+ tmp = BasicPSBT().parse(psbt)
+ po.inputs += tmp.inputs
+ po.input_count += 1
+ n = node.subkey_for_path("0/0")
+ sk = bytes(n.node.private_key).hex()
+ pk = n.node.private_key.K.sec().hex()
+ wifs.append((pk, sk))
+
+ # add simulator input
+ psbt = fake_txn(1, 1, segwit_in=True, psbt_v2=True)
+ tmp = BasicPSBT().parse(psbt)
+ po.inputs += tmp.inputs
+ po.input_count += 1
+
+
+ settings_set("wifs", wifs)
+
+ # convert to v0 PSBT just for fun
+ start_sign(po.to_v0(), finalize=True)
+ title, story = cap_story()
+ assert "warning" in story
+ assert "WIF store: 0, 1" in story
+
+ end_sign(finalize=True)
+
# EOF
diff --git a/testing/test_ux.py b/testing/test_ux.py
index 8a86e31..94db188 100644
--- a/testing/test_ux.py
+++ b/testing/test_ux.py
@@ -1,11 +1,12 @@
# (c) Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
import pytest, time, os, re, hashlib, shutil
-from helpers import xfp2str, prandom
-from charcodes import KEY_DOWN, KEY_QR, KEY_NFC, KEY_DELETE, KEY_CANCEL
+from helpers import xfp2str, prandom, addr_from_display_format
+from charcodes import KEY_DOWN, KEY_QR, KEY_NFC, KEY_DELETE, KEY_UP
from constants import AF_CLASSIC, simulator_fixed_words, simulator_fixed_xfp
from mnemonic import Mnemonic
-from bip32 import BIP32Node
+from bip32 import BIP32Node, PrivateKey
+from base58 import encode_base58_checksum
mnem = Mnemonic('english')
wordlist = mnem.wordlist
@@ -1229,6 +1230,331 @@ def test_file_picker_suffixes(pick_menu_item, goto_home, cap_story, microsd_wipe
microsd_wipe()
+@pytest.mark.parametrize("num_wifs", [1, 11])
+@pytest.mark.parametrize("separator", ["\n", ','])
+@pytest.mark.parametrize("way", ["sd", "nfc", "qr", "vdisk"])
+def test_wif_store_import(num_wifs, separator, way, import_wif_to_store, skip_if_useless_way,
+ settings_remove, goto_home):
+ skip_if_useless_way(way)
+ settings_remove("wifs")
+
+ wif_list = [
+ encode_base58_checksum(bytes([239]) + os.urandom(32) + b'\x01')
+ for _ in range(num_wifs)
+ ]
+
+ import_wif_to_store(wif_list, way=way, sep=separator)
+ goto_home()
+
+
+def test_wif_store_import_manual(import_wif_to_store, settings_remove, goto_home):
+ settings_remove("wifs")
+
+ wif_list = [
+ encode_base58_checksum(bytes([239]) + os.urandom(32) + b'\x01')
+ ]
+
+ import_wif_to_store(wif_list, way="input")
+ goto_home()
+
+
+@pytest.mark.parametrize("wif,err,way", [
+ ("cWALDjUu1tszsCBMjBjL4mhYj2wHUWYDR8Q8aSjLKzjkWaXMLRaY", "wif decode", "sd"), # curve order
+ ("cMahea7zqjxrtgAbB7LSGbcQUr1uX1ojuat9jZodMN87J7g8rY9t", "wif decode", "nfc"), # zero
+ ("Ky2BtsR8qRN91PjktxaTQWMgJZUWSBJLjwip642vvoNyH1PeEpUP", "chain", "qr"), # mainnet key on testnet
+ ("91zb4oYGEvwEroihAbkdeoBpLSKnZYMdD1CPhfQD76fxrfNSp5J", "compressed only", "sd"), # uncompressed pk
+ ("cPPBMnQzGV4QAqD2HNPamprjvnmv6dQ2oysHCUVSRv2yXkVvWVtX", "wif decode", "nfc"), # wrong csum
+ ("cPPBMnQzGV4QAqD2HNPamprjvnmv6dQ2oysHCUVSRv2yXkVvWVtX;cN7M6sNzn4LGBxAozsmphxjuxVNaHcLre7Nm163qM3DpY3BZog1v", "wif decode", "sd"), # wrong separator
+])
+def test_wif_store_import_fail(way, wif, err, import_wif_to_store, skip_if_useless_way,
+ settings_remove, press_select, cap_story, use_testnet, settings_get):
+ skip_if_useless_way(way)
+ use_testnet()
+ settings_remove("wifs")
+
+ import_wif_to_store([wif], way=way, early_exit=True)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert "Failed to import WIF" in story
+ assert err in story
+ press_select()
+ assert not settings_get("wifs")
+
+
+@pytest.mark.parametrize("netcode", ["XTN", "BTC"])
+def test_wif_store_detail(netcode, import_wif_to_store, use_mainnet, cap_menu, pick_menu_item,
+ cap_story, need_keypress, settings_remove, cap_screen_qr, is_q1,
+ press_cancel, nfc_is_enabled, press_nfc, nfc_read_text, goto_home):
+ goto_home()
+ if netcode == "BTC":
+ use_mainnet()
+
+ settings_remove("wifs")
+
+ prefix = bytes([128]) if netcode == "BTC" else bytes([239])
+ privkeys = [PrivateKey.parse(os.urandom(32)) for _ in range(5)]
+ wif_list = [
+ encode_base58_checksum(prefix + bytes(sk) + b'\x01')
+ for sk in privkeys
+ ]
+
+ import_wif_to_store(wif_list)
+
+ time.sleep(.1)
+ menu = cap_menu()
+ target_mi = []
+ for mi in menu:
+ if "⋯" in mi:
+ target_mi.append(mi)
+
+ assert len(target_mi) == len(wif_list)
+ for mi, wif, sk in zip(target_mi, wif_list, privkeys):
+ mi_split = mi.split(" ")[-1].split("⋯")
+ assert len(mi_split) == 2
+ assert mi_split[0] in wif
+ assert mi_split[1] in wif
+ pick_menu_item(mi)
+
+ time.sleep(.1)
+ menu = cap_menu()
+ assert menu[0] == "Detail"
+ assert menu[1] == "Addresses"
+ assert menu[2] == "Sign MSG"
+ assert menu[3] == "Delete"
+
+ pick_menu_item("Detail")
+
+ title, story = cap_story()
+ assert title == "WIF"
+
+ split_story = story.split("\n\n")
+ story_wif = split_story[0]
+ story_sk = split_story[1].split("\n")[-1]
+ story_pk = split_story[2].split("\n")[-1]
+
+ assert f'{KEY_QR if is_q1 else "(4)"} to show QR code' in story
+
+ need_keypress(KEY_QR if is_q1 else "4")
+ time.sleep(.1)
+ wif_qr = cap_screen_qr().decode()
+ press_cancel()
+
+ if nfc_is_enabled():
+ assert f"{KEY_NFC if is_q1 else '(3)'} to share via NFC" in story
+
+ press_nfc()
+ time.sleep(0.3)
+ nfc_wif = nfc_read_text()
+ time.sleep(0.3)
+ press_cancel()
+ assert nfc_wif == wif
+
+
+ assert story_wif == wif == wif_qr
+ assert story_sk == bytes(sk).hex()
+ assert story_pk == sk.K.sec().hex()
+
+ press_cancel() # exit Detail
+ press_cancel() # exit WIF submenu
+
+
+@pytest.mark.parametrize("netcode", ["XTN", "BTC"])
+def test_wif_store_addresses(netcode, import_wif_to_store, use_mainnet, cap_menu, pick_menu_item,
+ cap_story, need_keypress, settings_remove, cap_screen_qr, is_q1,
+ nfc_is_enabled, press_nfc, nfc_read_text, goto_home, press_cancel):
+ goto_home()
+ if netcode == "BTC":
+ use_mainnet()
+
+ settings_remove("wifs")
+
+ prefix = bytes([128]) if netcode == "BTC" else bytes([239])
+ n = BIP32Node.from_master_secret(os.urandom(32))
+ privkey = n.node.private_key
+
+ wif_list = [
+ encode_base58_checksum(prefix + bytes(privkey) + b'\x01')
+ ]
+
+ import_wif_to_store(wif_list)
+
+ time.sleep(.1)
+ menu = cap_menu()
+ assert len(menu) == 2
+ pick_menu_item(menu[1])
+ pick_menu_item("Addresses")
+
+ for mi, af in [("P2SH-Segwit", "p2sh-p2wpkh"), ("Segwit P2WPKH", "p2wpkh"), ("Classic P2PKH", "p2pkh")]:
+ pick_menu_item(mi)
+ time.sleep(.1)
+ title, story = cap_story()
+ if is_q1:
+ # Q has title as it needs hint keys
+ assert title == mi
+
+ target_addr = n.address(addr_fmt=af, netcode=netcode)
+ addr = addr_from_display_format(story.split("\n\n")[0])
+ assert addr == target_addr
+
+ if not is_q1:
+ assert "Press (1) to show address QR code." in story
+
+ need_keypress(KEY_QR if is_q1 else "1")
+ time.sleep(.1)
+ qr_addr = cap_screen_qr().decode()
+ if af == "p2wpkh":
+ qr_addr = qr_addr.lower()
+ press_cancel()
+ assert qr_addr == target_addr
+
+ if nfc_is_enabled():
+ if not is_q1:
+ assert "(3) to share via NFC." in story
+
+ press_nfc()
+ time.sleep(0.3)
+ nfc_addr = nfc_read_text()
+ time.sleep(0.3)
+ press_cancel()
+ assert nfc_addr == target_addr
+
+ press_cancel()
+ press_cancel()
+ press_cancel()
+
+
+def test_wif_store_clear_all(import_wif_to_store, press_select, cap_story, settings_get,
+ need_keypress, cap_menu, settings_remove, is_q1, goto_home):
+
+ goto_home()
+ settings_remove("wifs")
+ wif_list = [
+ encode_base58_checksum(bytes([239]) + os.urandom(32) + b'\x01')
+ for _ in range(30) # MAX
+ ]
+ import_wif_to_store(wif_list)
+ time.sleep(.1)
+
+ menu = cap_menu()
+ assert "Import WIF" not in menu # WIF store is full
+ assert "Clear All" in menu
+ need_keypress(KEY_UP if is_q1 else "5")
+ time.sleep(.1)
+ press_select() # on Clear All
+ time.sleep(.1)
+ title, story = cap_story()
+ assert "Remove all saved WIF keys?" in story
+ assert "(4)" in story
+ press_select() # does not work & gets you back to menu
+ assert len(settings_get("wifs")) == 30
+
+ press_select() # on Clear All
+ time.sleep(.1)
+ need_keypress("4")
+ time.sleep(.1)
+ menu = cap_menu()
+ assert len(menu) == 2
+ assert "(none yet)" in menu
+ assert "Import WIF" in menu
+ assert not settings_get("wifs")
+
+
+def test_wif_store_capacity(import_wif_to_store, settings_remove, press_select, cap_story,
+ settings_get, cap_menu, pick_menu_item, need_keypress):
+ settings_remove("wifs")
+
+ wif_list = [
+ encode_base58_checksum(bytes([239]) + os.urandom(32) + b'\x01')
+ for _ in range(40) # MAX+1
+ ]
+
+ import_wif_to_store(wif_list[:31], early_exit=True)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert title == "Failure"
+ assert "Max 30 items allowed in WIF Store" in story
+ assert "Attempted to import 31 keys" in story
+ assert "remaining WIF store capacity is only 30"
+ press_select()
+ assert not settings_get("wifs")
+
+ # import 29 keys
+ import_wif_to_store(wif_list[:29])
+
+ assert len(settings_get("wifs", [])) == 29
+
+ import_wif_to_store(wif_list[-2:], early_exit=True)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert title == "Failure"
+ assert "Max 30 items allowed in WIF Store" in story
+ assert "Attempted to import 2 keys" in story
+ assert "remaining WIF store capacity is only 1"
+ press_select()
+
+ assert len(settings_get("wifs", [])) == 29
+
+ import_wif_to_store(wif_list[-1:])
+ assert len(settings_get("wifs", [])) == 30
+
+ menu = cap_menu()
+ assert "Import WIF" not in menu
+ # remove random key to make space
+ # pick key at current menu item position
+ press_select()
+ time.sleep(.1)
+ pick_menu_item("Delete")
+ time.sleep(.1)
+ title, story = cap_story()
+ assert "Delete WIF key?" in story
+ press_select()
+ time.sleep(.1)
+ menu = cap_menu()
+ assert "Import WIF" in menu
+
+
+def test_wif_store_import_duplicate(settings_remove, import_wif_to_store, settings_get, cap_menu,
+ goto_home):
+ goto_home()
+ settings_remove("wifs")
+
+ wif_list = [
+ encode_base58_checksum(bytes([239]) + os.urandom(32) + b'\x01')
+ for _ in range(4)
+ ]
+
+ import_wif_to_store(wif_list)
+ b4 = cap_menu()
+ assert len(settings_get("wifs")) == 4
+
+ import_wif_to_store(wif_list, early_exit=True)
+ assert len(settings_get("wifs")) == 4
+ assert len(b4) == len(cap_menu())
+
+ import_wif_to_store(wif_list[:1], early_exit=True)
+ assert len(b4) == len(cap_menu())
+ assert len(settings_get("wifs")) == 4
+
+
+@pytest.mark.parametrize("way", ["qr", "sd", "nfc"])
+def test_wif_store_export_all(way, goto_home, settings_remove, import_wif_to_store, pick_menu_item,
+ load_export):
+ goto_home()
+ settings_remove("wifs")
+
+ wif_list = [
+ encode_base58_checksum(bytes([239]) + os.urandom(32) + b'\x01')
+ for _ in range(6) # 6*52 chars so it can be shown on mk4 too
+ ]
+
+ import_wif_to_store(wif_list)
+ time.sleep(.1)
+ pick_menu_item("Export All")
+ conts = load_export(way, "WIF Store", is_json=False, sig_check=False)
+
+ assert wif_list == conts.split("\n")
+
+
@pytest.mark.onetime
def test_dump_menutree(sim_execfile):
# saves to ../unix/work/menudump.txt
Why this scored 59/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.