firmware/shared/wif.py
2026-06-22 12:46:50 -04:00

424 lines
14 KiB
Python

# (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