# (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, CardMissingError, needs_microsd from glob import settings from charcodes import KEY_QR, KEY_NFC, KEY_CANCEL from public_constants import AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH, AF_P2SH from msgsign import msg_signing_done MAX_ITEMS = 30 def decode_wif(wif): # Decode base58 encoded WIF string, return keypair and metadata 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(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, chains.current_chain().address(node, addr_fmt) def save_wif_store_items(new_wifs): saved = settings.get("wifs", []) len_saved = len(saved) unique = [] dups = 0 for item in new_wifs: if item in unique: continue if item not in saved: unique.append(item) else: dups += 1 err = ("No valid WIF key found." + (" Contains duplicate WIF(s)" if dups else "")) assert unique, err err = ("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." % (MAX_ITEMS, len(unique), MAX_ITEMS - len_saved)) assert (len_saved + len(unique)) <= MAX_ITEMS, err saved.extend(unique) settings.set('wifs', saved) settings.save() return len(unique) async def ux_visualize_wif(wif_str, kp, compressed, testnet): ch_str = ("XTN" if testnet else "BTC") sk = b2a_hex(kp.privkey()).decode() pk = b2a_hex(kp.pubkey().to_bytes(not compressed)).decode() msg = "%s\n\nchain: %s\n\nPrivkey:\n%s\n\nPubkey:\n%s" % (wif_str, ch_str, sk, pk) esc = "" if compressed and (testnet == (chains.current_chain().ctype != "BTC")): # we only support compressed in WIF store msg += "\n\nPress (1) to import to WIF Store." esc += "1" ch = await ux_show_story(msg, title="WIF Key", escape=esc) if ch == "1": title = "Success" try: save_wif_store_items([(pk, sk)]) msg = "Saved to WIF Store." except Exception as e: title = "Failure" msg = str(e) await ux_show_story(msg, title=title) class WIFStoreMenu(MenuSystem): def __init__(self): items = self.construct() super().__init__(items) @classmethod async def make(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) < 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("Descriptors", f=self.show_desc_step1, arg=pk), 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_desc_step1(self, a, b, item): rv = [ MenuItem(chains.addr_fmt_label(af), f=self.show_desc_step2, arg=(item.arg, af)) for af in chains.SINGLESIG_AF ] the_ux.push(MenuSystem(rv)) async def show_desc_step2(self, a, b, item): # allow to export pubkey, instead of main detail where WIF is exported pk, af = item.arg title = "Descriptor" if af == AF_P2WPKH: desc = "wpkh(%s)" elif af == AF_CLASSIC: desc = "pkh(%s)" else: assert af == AF_P2WPKH_P2SH desc = "sh(wpkh(%s))" from descriptor import append_checksum desc = append_checksum(desc % pk) from export import export_contents await export_contents(title, desc, "wif_desc_%d.txt" % af, None, None, force_prompt=True, intro=desc, ux_title=title) async def show_addr_step1(self, a, b, item): rv = [ MenuItem(chains.addr_fmt_label(af), f=self.show_addr_step2, arg=(item.arg, af)) for af in chains.SINGLESIG_AF ] the_ux.push(MenuSystem(rv)) async def show_addr_step2(self, a, b, item): pubkey, af = item.arg node = node_from_pubkey(a2b_hex(pubkey)) addr = chains.current_chain().address(node, af) msg = show_single_address(addr) ux_title = chains.addr_fmt_label(af) if version.has_qwerty else None from export import export_contents await export_contents("Address", addr, "wif_addr.txt", None, None, force_prompt=True, intro=msg, ux_title=ux_title) 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: entry = wifs[idx] assert entry[0] == pubkey del wifs[idx] settings.set('wifs', wifs) settings.save() except IndexError: return 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 # - kinda big so we can import paper wallet directly fn = await file_picker(suffix=['.csv', '.txt'], min_size=51, max_size=11000, none_msg="Must contain WIF(s)", **ch) if not fn: return try: with CardSlot(readonly=True, **ch) as card: with open(fn, 'rt') as fd: got = fd.read() except CardMissingError: await needs_microsd() return except Exception as e: await ux_show_story('Failed to read file!\n\n%s' % e) return if not got: return dis.fullscreen("Wait...") # allow commas, spaces, and newlines as separators got = got.replace(',', ' ').split() try: new_wifs = [] for here in got: here = here.strip() if not here: continue try: kp, testnet, compressed = decode_wif(here) except Exception: # ignore garbage text, headers, addresses, etc. continue 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() new_wifs.append((pk, sk)) save_wif_store_items(new_wifs) 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") class WIFStore: def __init__(self): wifs = settings.get('wifs', []) self.wifs = [] # max 30 items, each (pubkey, privkey) for pk, sk in wifs: self.wifs.append((a2b_hex(pk), a2b_hex(sk))) # built lazily, on first match_address_hash() call self._pkh = [] # hash160(pubkey) — P2PKH / P2WPKH self._sh = [] # hash160(0014 || _pkh) — P2SH-P2WPKH def __bool__(self): return len(self.wifs) > 0 def __contains__(self, pubkey): return self._privkey_for(pubkey) is not None def __getitem__(self, pubkey): sk = self._privkey_for(pubkey) if sk is None: raise KeyError return sk def _privkey_for(self, pubkey): for pk, sk in self.wifs: if pk == pubkey: return sk def match_address_hash(self, addr_fmt, hash20): if not self.wifs: return None if not self._pkh: self._pkh = [ngu.hash.hash160(pk) for pk, _ in self.wifs] if addr_fmt in (AF_P2WPKH, AF_CLASSIC): table = self._pkh elif addr_fmt == AF_P2SH: if not self._sh: self._sh = [ngu.hash.hash160(b'\x00\x14' + h) for h in self._pkh] table = self._sh else: return None # AF_P2WSH / AF_P2TR / AF_BARE_PK / unknown — not us try: idx = table.index(hash20) return idx, self.wifs[idx][0] except ValueError: return None # EOF