SeedQR & XOR Seed

This commit is contained in:
scgbckbone 2024-07-19 15:48:53 +02:00 committed by doc-hex
parent 457d3bd8a3
commit ea619e0596
11 changed files with 196 additions and 92 deletions

View File

@ -25,6 +25,7 @@ This lists the new changes that have not yet been published in a normal release.
## 1.2.4Q - 2024-08-xx
- New Feature: Seed XOR with SeedQR
- New Feature: (BB)QR file share
- Bugfix: Properly clear LCD screen after BBQR is shown
- Bugfix: Writing to empty slot B caused broken card reader

View File

@ -8,7 +8,7 @@ import ckcc, pyb, version, uasyncio, sys, uos
from uhashlib import sha256
from uasyncio import sleep_ms
from ubinascii import hexlify as b2a_hex
from utils import imported, problem_file_line, get_filesize
from utils import imported, problem_file_line, get_filesize, encode_seed_qr
from utils import xfp2str, B2A, addr_fmt_label, txid_from_fname
from ux import ux_show_story, the_ux, ux_confirm, ux_dramatic_pause, ux_aborted
from ux import ux_enter_bip32_index, ux_input_text, import_export_prompt
@ -719,7 +719,7 @@ async def export_seedqr(*a):
words = bip39.b2a_words(sv.raw).split(' ')
dis.busy_bar(False)
qr = ''.join('%04d'% bip39.get_word_index(w) for w in words)
qr = encode_seed_qr(words)
del words

View File

@ -2,12 +2,21 @@
#
# decoders.py - Convert QR (or text) values into useful bitcoin-related objects.
#
import ngu, bip39, ure
# included in Q builds only, not Mk4 --> manifest_q1.py
#
import ngu, bip39, ure, stash
from ubinascii import unhexlify as a2b_hex
from exceptions import QRDecodeExplained
from bbqr import TYPE_LABELS
from utils import decode_bip21_text
def decode_seed_qr(data):
# SeedQR: 4 digit groups of index into word list
parts = [data[pos:pos + 4] for pos in range(0, len(data), 4)]
words = [bip39.wordlist_en[int(n)] for n in parts]
return words
def txn_decoding_taster(txt):
# look at first 4 bytes, and assume it's txn version number (LE32 0x1 or 0x2), then decode
# - working in normal RAM, won't handle full sized txn
@ -66,17 +75,15 @@ def decode_secret(got):
taste = got.strip().lower()
if taste.isdigit():
# SeedQR: 4 digit groups of index into word list
parts = [taste[pos:pos+4] for pos in range(0, len(taste), 4)]
try:
assert len(parts) in (12, 18, 24)
words = [bip39.wordlist_en[int(n)] for n in parts]
words = decode_seed_qr(taste)
except:
raise ValueError('corrupt SeedQR?')
assert len(words) in stash.SEED_LEN_OPTS, "seed len"
return 'words', words
words = taste.strip().split(' ')
if len(words) in [ 12, 18, 24]:
if len(words) in stash.SEED_LEN_OPTS:
# looks like bip-39 words, decode and re-expand
idx = [bip39.get_word_index(w) for w in words]
return 'words', [bip39.wordlist_en[n] for n in idx]

View File

@ -59,7 +59,7 @@ def bip85_derive(picked, index):
if picked in (0,1,2):
# BIP-39 seed phrases (we only support English)
num_words = (12, 18, 24)[picked]
num_words = stash.SEED_LEN_OPTS[picked]
width = (16, 24, 32)[picked] # of bytes
path = "m/83696968h/39h/0h/{num_words}h/{index}h".format(num_words=num_words, index=index)
s_mode = 'words'

View File

@ -7,7 +7,7 @@
# - has GPIO signal "??" which is multipurpose on its own pin
# - this chip chosen because it can disable RF interaction
#
import utime, ngu, ndef
import utime, ngu, ndef, stash
from uasyncio import sleep_ms
import uasyncio as asyncio
from ustruct import pack, unpack
@ -693,7 +693,7 @@ class NFCHandler:
for urn, msg, meta in ndef.record_parser(data):
msg = bytes(msg).decode().strip() # from memory view
split_msg = msg.split(" ")
if len(split_msg) in (12, 18, 24):
if len(split_msg) in stash.SEED_LEN_OPTS:
winner = split_msg
break

View File

@ -48,8 +48,14 @@ class QRDisplaySingle(UserInteraction):
# can fail if not enough space in QR
self.qr_data = uqr.make(msg, min_version=2,
max_version=11 if not has_qwerty else 25,
encoding=enc)
max_version=11 if not has_qwerty else 25,
encoding=enc)
def idx_hint(self):
# draw_qr_display takes this and renders hint in the top right corner
# this member function decides what type of hint will be shown
# numbers, letters, etc.
return str(self.start_n + self.idx) if len(self.addrs) > 1 else None
def redraw(self):
# Redraw screen.
@ -66,10 +72,9 @@ class QRDisplaySingle(UserInteraction):
self.calc_qr(body)
# draw display
idx_hint = str(self.start_n + self.idx) if len(self.addrs) > 1 else None
dis.busy_bar(False)
dis.draw_qr_display(self.qr_data, self.msg or body, self.is_alnum,
self.sidebar, idx_hint, self.invert)
self.sidebar, self.idx_hint(), self.invert)
async def interact_bare(self):
from glob import NFC, dis
@ -116,4 +121,10 @@ class QRDisplaySingle(UserInteraction):
await self.interact_bare()
the_ux.pop()
class XORQRDisplaySingle(QRDisplaySingle):
def idx_hint(self):
if len(self.addrs) > 1:
return chr(65+int(self.start_n + self.idx))
# EOF

View File

@ -15,6 +15,9 @@ from uhashlib import sha256
from utils import swab32, call_later_ms, B2A
SEED_LEN_OPTS = [12, 18, 24]
class ZeroSecretException(ValueError):
# raised when there is no secret or secret is zero
pass
@ -43,7 +46,7 @@ def len_to_numwords(vlen):
def numwords_to_len(num_words):
# map number of BIP-39 seed words to length of binary secret
assert num_words in [12, 18, 24]
assert num_words in SEED_LEN_OPTS
return (num_words * 8) // 6
class SecretStash:

View File

@ -2,7 +2,7 @@
#
# utils.py - Misc utils. My favourite kind of source file.
#
import gc, sys, ustruct, ngu, chains, ure, time
import gc, sys, ustruct, ngu, chains, ure, time, bip39
from ubinascii import unhexlify as a2b_hex
from ubinascii import hexlify as b2a_hex
from ubinascii import a2b_base64, b2a_base64
@ -615,13 +615,26 @@ def datetime_to_str(dt, fmt="%d-%02d-%02d %02d:%02d:%02d"):
dts = fmt % (y, mo, d, h, mi, s)
return dts + " UTC"
def censor_address(addr):
# We don't like to show the user multisig addresses because we cannot be certain
# they are valid and could actually be signed. And yet, dont blank too many
# spots or else an attacker could grind out a suitable replacement.
return addr[0:12] + '___' + addr[12+3:]
def txid_from_fname(fname):
if len(fname) >= 64:
txid = fname[:64]
try:
a2b_hex(txid)
return txid
except: pass
return None
def url_decode(u):
# expand control chars from %XX and '+'
# - equiv to urllib.parse.unquote_plus
# - ure.sub is missing, so not being clever here.
# - give up on syntax errors, and return unchanged
import ure
u = u.replace('+', ' ')
while 1:
pos = u.find('%')
@ -685,19 +698,7 @@ def decode_bip21_text(got):
raise ValueError('not bip-21')
def censor_address(addr):
# We don't like to show the user multisig addresses because we cannot be certain
# they are valid and could actually be signed. And yet, dont blank too many
# spots or else an attacker could grind out a suitable replacement.
return addr[0:12] + '___' + addr[12+3:]
def txid_from_fname(fname):
if len(fname) >= 64:
txid = fname[:64]
try:
a2b_hex(txid)
return txid
except: pass
return None
def encode_seed_qr(words):
return ''.join('%04d' % bip39.get_word_index(w) for w in words)
# EOF

View File

@ -605,11 +605,16 @@ async def seed_word_entry(prompt, num_words, has_checksum=True, done_cb=None):
assert num_words and prompt and done_cb
words = ['' for _ in range(num_words)]
def redraw_words(wrds=None):
if not wrds:
wrds = ['' for _ in range(num_words)]
dis.clear()
dis.text(None, 0, prompt, invert=1)
pos = ux_draw_words(2 if num_words != 24 else 1, num_words, words)
dis.clear()
dis.text(None, 0, prompt, invert=1)
p = ux_draw_words(2 if num_words != 24 else 1, num_words, wrds)
return wrds, p
words, pos = redraw_words()
word_num = 0
value = ''
@ -647,7 +652,28 @@ async def seed_word_entry(prompt, num_words, has_checksum=True, done_cb=None):
commit = False
final = (word_num == num_words)
if ch == KEY_ENTER:
if ch == KEY_QR:
try:
got = await QRScannerInteraction.scan('Scan seed from a QR code')
what, vals = decode_qr_result(got, expect_secret=True)
except QRDecodeExplained:
redraw_words(words)
continue
if what != "words":
err_msg = "Must be seed words, not %s" % what
elif num_words != len(vals[0]):
err_msg = "Must be seed of length %d, not %s" % (num_words, len(vals[0]))
else:
words = vals[0]
# offer just the actual imported csum if user deletes csum word
last_words = [words[-1]]
word_num = num_words
# needs redraw, empty on error with error below
# if success, qr imported words shown to user
redraw_words(words)
elif ch == KEY_ENTER:
if final:
break
commit = True

View File

@ -5,13 +5,14 @@
# - for secret spliting on paper
# - all combination of partial XOR seed phrases are working wallets
#
import stash, ngu, bip39, random
import stash, ngu, bip39, version
from ux import ux_show_story, the_ux, ux_confirm, ux_dramatic_pause, ux_render_words
from ux import show_qr_code
from seed import word_quiz, WordNestMenu, set_seed_value, set_ephemeral_seed
from glob import settings
from actions import goto_top_menu
from version import has_qwerty
from charcodes import KEY_CANCEL
from utils import encode_seed_qr
from charcodes import KEY_CANCEL, KEY_QR
def xor(*args):
@ -110,6 +111,15 @@ Otherwise, press OK to continue.'''.format(n=num_parts), escape='2')
if await ux_confirm("Stop and forget those words?"):
return
continue
if ch == KEY_QR:
qrs = []
for wl in word_parts:
qrs.append(encode_seed_qr(wl))
from qrs import XORQRDisplaySingle
o = XORQRDisplaySingle(qrs, True, 0, sidebar=None)
await o.interact_bare()
continue
for ws, part in enumerate(word_parts):
ch = await word_quiz(part, title='Word %s%%d is?' % chr(65+ws))
@ -127,6 +137,7 @@ import_xor_parts = []
async def xor_all_done(new_words):
# So we have another part, might be done or not.
global import_xor_parts
chk_words = None
import_xor_parts.append(new_words)
target_words = len(new_words)
@ -138,7 +149,8 @@ async def xor_all_done(new_words):
msg = "You've entered %d parts so far.\n\n" % num_parts
if num_parts >= 2:
chk_word = bip39.b2a_words(seed).split(' ')[-1]
chk_words = bip39.b2a_words(seed).split(' ')
chk_word = chk_words[-1]
msg += "If you stop now, the %dth word of the XOR-combined seed phrase\nwill be:\n\n" % target_words
msg += "%d: %s\n\n" % (target_words, chk_word)
@ -153,51 +165,53 @@ async def xor_all_done(new_words):
msg += " Or (2) if done with all words."
escape += "2"
ch = await ux_show_story(msg, strict_escape=True, escape='12x'+KEY_CANCEL, sensitive=True)
if ch == 'x':
# give up
import_xor_parts.clear() # concern: we are contaminated w/ secrets
return None
while True:
ch = await ux_show_story(msg, strict_escape=True, escape=escape, sensitive=True, hint_icons=KEY_QR)
if ch == 'x':
# give up
import_xor_parts.clear() # concern: we are contaminated w/ secrets
elif chk_words and ch == KEY_QR:
rv = encode_seed_qr(chk_words)
await show_qr_code(rv, True, msg="SeedQR")
continue
elif ch == '1':
# do another list of words
if version.has_qwerty:
from ux_q1 import seed_word_entry
await seed_word_entry("Part %s Words" % chr(65+len(import_xor_parts)),
target_words, done_cb=xor_all_done)
else:
nxt = XORWordNestMenu(num_words=target_words, done_cb=xor_all_done)
the_ux.push(nxt)
elif ch == '1':
# do another list of words
if has_qwerty:
from ux_q1 import seed_word_entry
await seed_word_entry("Part %s Words" % chr(65+len(import_xor_parts)),
target_words, done_cb=xor_all_done)
else:
nxt = XORWordNestMenu(num_words=target_words, done_cb=xor_all_done)
the_ux.push(nxt)
elif ch == '2':
# done; import on temp basis, or be the main secret
from pincodes import pa
enc = stash.SecretStash.encode(seed_phrase=seed)
elif ch == '2':
# done; import on temp basis, or be the main secret
from pincodes import pa
enc = stash.SecretStash.encode(seed_phrase=seed)
if pa.is_secret_blank():
# save it since they have no other secret
set_seed_value(encoded=enc)
# update menu contents now that wallet defined
goto_top_menu(first_time=True)
else:
# set as ephemeral seed, maybe save it too
# below is super costly as we need to bip32 generate master secret from entropy bytes
# only need XFPs for UI
# xfps = [
# xfp2str(swab32(
# stash.SecretStash.decode(stash.SecretStash.encode(seed_phrase=i))[2].my_fp()
# ))
# for i in enc_parts
# ]
await set_ephemeral_seed(
enc,
meta='SeedXOR(%d parts, check: "%s")' % (
num_parts, chk_word
if pa.is_secret_blank():
# save it since they have no other secret
set_seed_value(encoded=enc)
# update menu contents now that wallet defined
goto_top_menu(first_time=True)
else:
# set as ephemeral seed, maybe save it too
# below is super costly as we need to bip32 generate master secret from entropy bytes
# only need XFPs for UI
# xfps = [
# xfp2str(swab32(
# stash.SecretStash.decode(stash.SecretStash.encode(seed_phrase=i))[2].my_fp()
# ))
# for i in enc_parts
# ]
await set_ephemeral_seed(
enc,
meta='SeedXOR(%d parts, check: "%s")' % (
num_parts, chk_word
)
)
)
goto_top_menu()
return None
goto_top_menu()
break
class XORWordNestMenu(WordNestMenu):
def tr_label(self):
@ -268,10 +282,10 @@ or press (2) for 18 words XOR.''', escape="12")
words = bip39.b2a_words(sv.raw).split(' ')
import_xor_parts.append(words)
if has_qwerty:
if version.has_qwerty:
from ux_q1 import seed_word_entry
# if current loaded seed is added to xor - it is always A
await seed_word_entry("Part %s Words" % ("B" if import_xor_parts else "A"),
await seed_word_entry("Part %s Words" % (chr(65+len(import_xor_parts))),
desired_num_words, done_cb=xor_all_done)
else:
return XORWordNestMenu(num_words=desired_num_words, done_cb=xor_all_done)

View File

@ -8,6 +8,7 @@ from mnemonic import Mnemonic
from constants import simulator_fixed_words
from xor import prepare_test_pairs
from test_ux import word_menu_entry, pass_word_quiz
from charcodes import KEY_QR, KEY_RIGHT
wordlist = Mnemonic('english').wordlist
@ -54,7 +55,7 @@ def restore_seed_xor(set_seed_words, goto_home, pick_menu_item, cap_story,
confirm_tmp_seed, seed_vault_enable, press_select,
scan_a_qr, is_q1, cap_screen_qr, cap_screen):
def doit(parts, expect, incl_self=False, save_to_vault=False,
is_master_tmp_fail=False):
is_master_tmp_fail=False, way=None):
if expect is None:
parts, expect = prepare_test_pairs(*parts)
@ -92,6 +93,7 @@ def restore_seed_xor(set_seed_words, goto_home, pick_menu_item, cap_story,
else:
press_select()
wordlist = Mnemonic('english').wordlist
for n, part in enumerate(parts):
if n == 0 and incl_self:
continue
@ -104,9 +106,25 @@ def restore_seed_xor(set_seed_words, goto_home, pick_menu_item, cap_story,
else:
assert what in scr
word_menu_entry(part.split())
if way and "qr" in way:
assert is_q1
need_keypress(KEY_QR)
time.sleep(.1)
if way == "seedqr":
qr = ''.join('%04d' % wordlist.index(w) for w in part.split())
else:
qr = ' '.join(w[:4] for w in part.split())
scan_a_qr(qr)
for _ in range(20):
scr = cap_screen()
if 'Valid words' in scr:
break
time.sleep(.1)
press_select()
else:
word_menu_entry(part.split())
time.sleep(0.01)
time.sleep(.1)
title, body = cap_story()
assert f"You've entered {n + 1} parts so far" in body
if n+1 > 1:
@ -123,6 +141,13 @@ def restore_seed_xor(set_seed_words, goto_home, pick_menu_item, cap_story,
if expect == zeros[num_words]:
assert 'ZERO WARNING' in body
if is_q1:
need_keypress(KEY_QR)
qr = cap_screen_qr().decode('ascii')
parts = [qr[pos:pos + 4] for pos in range(0, len(qr), 4)]
assert [wordlist[int(n)] for n in parts] == expect.split()
press_select()
need_keypress('2')
try:
confirm_tmp_seed(seedvault=save_to_vault)
@ -139,6 +164,7 @@ def restore_seed_xor(set_seed_words, goto_home, pick_menu_item, cap_story,
return doit
@pytest.mark.parametrize('way', ["qr", "seedqr", "classic"])
@pytest.mark.parametrize('incl_self', [False, True])
@pytest.mark.parametrize('seed_vault', [False, True])
@pytest.mark.parametrize('parts, expect', [
@ -160,8 +186,10 @@ def restore_seed_xor(set_seed_words, goto_home, pick_menu_item, cap_story,
# random generated
*random_test_cases()
])
def test_import_xor(seed_vault, incl_self, parts, expect, restore_seed_xor):
restore_seed_xor(parts, expect, incl_self, seed_vault)
def test_import_xor(seed_vault, incl_self, parts, expect, restore_seed_xor, way, is_q1):
if not is_q1 and "qr" in way:
raise pytest.skip("Q only")
restore_seed_xor(parts, expect, incl_self, seed_vault, way=way)
@pytest.mark.parametrize('incl_self', [False, True])
@ -186,7 +214,7 @@ def test_import_xor_zeros_ones(incl_self, parts, expect, restore_seed_xor):
@pytest.mark.parametrize('trng', [False, True])
def test_xor_split(num_words, qty, trng, goto_home, pick_menu_item, cap_story, need_keypress,
cap_menu, get_secrets, pass_word_quiz, set_seed_words, press_select,
seed_story_to_words, is_q1):
seed_story_to_words, is_q1, cap_screen_qr):
set_seed_words(proper[num_words])
@ -228,6 +256,19 @@ def test_xor_split(num_words, qty, trng, goto_home, pick_menu_item, cap_story, n
assert all(len(prt) == num_words for prt in parts)
chk_word = seed_story_to_words(chk_prt)[0]
assert chk_word
need_keypress(KEY_QR)
p_all = []
for i in range(len(parts)):
p = cap_screen_qr().decode("ascii") # SeedQR
pparts = [p[pos:pos + 4] for pos in range(0, len(p), 4)]
pwords = [wordlist[int(n)] for n in pparts]
p_all.append(pwords)
need_keypress(KEY_RIGHT)
time.sleep(.1)
press_select() # exit QR display
assert p_all == parts
else:
words = [ln[4:] for ln in body.split('\n') if ln[2:4] == ': ']
parts = [words[pos:pos + num_words] for pos in range(0, num_words * qty, num_words)]