diff --git a/shared/backups.py b/shared/backups.py index 03ec663a..17acb215 100644 --- a/shared/backups.py +++ b/shared/backups.py @@ -61,17 +61,26 @@ def render_backup_contents(): # BTW: everything is really a duplicate of this value ADD('raw_secret', b2a_hex(sv.secret).rstrip(b'0')) - # XXX mk4 a bit different - if pa.has_duress_pin(): - COMMENT('Duress Wallet (informational)') - dpk, p = sv.duress_root() - COMMENT('path = %s' % p) - ADD('duress_xprv', chain.serialize_private(dpk)) - ADD('duress_xpub', chain.serialize_public(dpk)) - if version.has_608: # save the so-called long-secret ADD('long_secret', b2a_hex(pa.ls_fetch())) + + # Duress wallets (somewhat optional, since derived) + if version.mk_num <= 3: + if pa.has_duress_pin(): + COMMENT('Duress Wallet (informational)') + dpk, p = sv.duress_root() + COMMENT('path = %s' % p) + ADD('duress_xprv', chain.serialize_private(dpk)) + ADD('duress_xpub', chain.serialize_public(dpk)) + else: + from trick_pins import tp + for label, path, pairs in tp.backup_duress_wallets(sv): + COMMENT() + COMMENT(label + ' (informational)') + COMMENT(path) + for k,v in pairs: + ADD(k, v) COMMENT('Firmware version (informational)') date, vers, timestamp = version.get_mpy_version()[0:3] @@ -98,9 +107,10 @@ def render_backup_contents(): return rv.getvalue() -async def restore_from_dict(vals): +def restore_from_dict_ll(vals): # Restore from a dict of values. Already JSON decoded. - # Reboot on success, return string on failure + # Need a Reboot on success, return string on failure + # - low-level version, factored out for better testing from glob import dis #print("Restoring from: %r" % vals) @@ -169,6 +179,16 @@ async def restore_from_dict(vals): if k == 'xfp' or k == 'xpub': continue + if k == 'tp': + # restore trick pins, which may involve many ops + if version.mk_num >= 4: + from trick_pins import tp + try: + tp.restore_backup(vals[k]) + except Exception as exc: + sys.print_exception(exc) + continue + settings.set(k[8:], vals[k]) # write out @@ -178,6 +198,14 @@ async def restore_from_dict(vals): import hsm hsm.restore_backup(vals['hsm_policy']) + +async def restore_from_dict(vals): + # Restore from a dict of values. Already JSON decoded (ie. dict object). + # Need a Reboot on success, return string on failure + + prob = restore_from_dict_ll(vals) + if prob: return prob + await ux_show_story('Everything has been successfully restored. ' 'We must now reboot to install the ' 'updated settings and seed.', title='Success!') diff --git a/shared/trick_pins.py b/shared/trick_pins.py index 62d74466..5e76659a 100644 --- a/shared/trick_pins.py +++ b/shared/trick_pins.py @@ -7,7 +7,7 @@ # - replaces old "duress wallet" and "brickme" features # - changes require knowledge of real PIN code (it is checked) # -import version, uctypes, errno, ngu, sys, ckcc, stash +import version, uctypes, errno, ngu, sys, ckcc, stash, bip39 from ubinascii import hexlify as b2a_hex from menu import MenuSystem, MenuItem from ux import ux_show_story, ux_confirm, ux_dramatic_pause, ux_enter_number, the_ux, ux_aborted @@ -64,6 +64,24 @@ up to last four digits can be different between true PIN and trick.''' % len(rig return None, a +def construct_duress_secret(flags, tc_arg): + # is duress wallet required and if so, what are the secret values (32 or 64 bytes) + if flags & TC_WORD_WALLET: + # derive the secret via BIP-85 + new_secret, _, _, path = bip85_derive(2, tc_arg) + path = "BIP85(words=24, index=%d)" % tc_arg + + elif flags & TC_XPRV_WALLET: + # use old method for duress wallets + with stash.SensitiveValues() as sv: + node, path = sv.duress_root() + new_secret = SecretStash.encode(xprv=node)[1:65] + assert len(new_secret) == 64 + else: + return (None, None) + + return path, new_secret + def make_slot(): b = bytearray(uctypes.sizeof(TRICK_SLOT_LAYOUT)) return b, uctypes.struct(uctypes.addressof(b), TRICK_SLOT_LAYOUT) @@ -257,6 +275,12 @@ class TrickPinMgmt: if flags & TC_DELTA_MODE: yield k + def get_deltamode_pins(self): + # iterate over all duress wallets + for k, (sn,flags,args) in self.tp.items(): + if flags & (TC_WORD_WALLET | TC_XPRV_WALLET): + yield k + def check_new_main_pin(self, pin): # user is trying to change main PIN to new value; check for issues # - dups bad but also: delta mode pin might not work w/ longer main true pin @@ -277,6 +301,64 @@ class TrickPinMgmt: assert not prob # see check_new_main_pin() above self.update_slot(d_pin.encode(), tc_arg=arg) + def backup_duress_wallets(self, sv): + # for backup file, yield (label, path, pairs-of-data) + done = set() + for pin in self.get_deltamode_pins(): + sn, flags, arg = self.tp[pin] + + if (flags, arg) in done: + continue + done.add( (flags, arg) ) + + if flags & TC_WORD_WALLET: + label = "Duress: BIP-85 Derived wallet" + path = "BIP85(words=24, index=%d)" % arg + b, slot = tp.get_by_pin(pin) + words = bip39.b2a_words(slot.xdata[0:32]) + + d = [ ('duress_%d_words' % arg, words) ] + elif flags & TC_XPRV_WALLET: + label = "Duress: XPRV Wallet" + node, path = sv.duress_root() + path = 'path = ' + path + # backwards compat name, but skipping xpub this time + d = [ ('duress_xprv', sv.chain.serialize_private(node)) ] + + yield (label, path, d) + + def restore_backup(self, vals): + # restoring backup value + # - need to re-populate SE2 w/ these values, including duress wallets + # - being restored: vals=self.tp + # - CAUTION: new true-pin may not match old true-pin; skip any that would + # not work w/ new pin (conflicting value, or deltamode issues) + from pincodes import pa + true_pin = pa.pin.decode() + + for pin in vals: + (sn, flags, arg) = vals[pin] + + if pin == true_pin: + # drop conflicting trick pin vs. (new) true pin + continue + + if flags & TC_DELTA_MODE: + prob = validate_delta_pin(true_pin, pin) + if prob: + # just forget it, no UI here to report issue + continue + + try: + # might need to construct a BIP-85 or XPRV secret to match + path, new_secret = construct_duress_secret(flags, arg) + + b, slot = tp.update_slot(pin.encode(), new=True, + tc_flags=flags, tc_arg=arg, secret=new_secret) + except Exception as exc: + sys.print_exception(exc) # not visible + + tp = TrickPinMgmt() class TrickPinMenu(MenuSystem): @@ -355,18 +437,8 @@ class TrickPinMenu(MenuSystem): msg += '\n\n' - path = None - new_secret = None - if flags & TC_WORD_WALLET: - # derive the secret via BIP-85 - new_secret, _, _, path = bip85_derive(2, tc_arg) - path = "BIP85(words=24, index=%d)" % tc_arg - elif flags & TC_XPRV_WALLET: - # use old method for duress wallets - with stash.SensitiveValues() as sv: - node, path = sv.duress_root() - new_secret = SecretStash.encode(xprv=node)[1:65] - assert len(new_secret) == 64 + path, new_secret = construct_duress_secret(flags, tc_arg) + if path: msg += "Duress wallet will use path:\n\n%s\n\n" % path diff --git a/testing/devtest/backups.py b/testing/devtest/backups.py index 261c92d8..ac85f56b 100644 --- a/testing/devtest/backups.py +++ b/testing/devtest/backups.py @@ -20,7 +20,7 @@ if 1: blanks = 0 checklist = set('mnemonic chain xprv xpub raw_secret fw_date fw_version fw_timestamp serial ' 'setting.terms_ok setting.idle_to setting.chain'.split(' ')) - optional = set('setting.pms setting.axi setting.nick setting.lgto setting.usr hsm_policy setting.words long_secret multisig setting.multisig setting.fee_limit setting.tp setting.check'.split(' ')) + optional = set('setting.pms setting.axi setting.nick setting.lgto setting.usr hsm_policy setting.words long_secret multisig setting.multisig setting.fee_limit setting.tp setting.check duress_xprv duress_xpub duress_1001_words duress_1002_words duress_1003_words'.split(' ')) for ln in render_backup_contents().split('\n'): ln = ln.strip() @@ -137,6 +137,7 @@ else: settings.clear() settings.save() +print("fully done") # EOF diff --git a/testing/test_se2.py b/testing/test_se2.py index 379a86b3..03dae612 100644 --- a/testing/test_se2.py +++ b/testing/test_se2.py @@ -304,8 +304,9 @@ def new_pin_confirmed(cap_menu, need_keypress, cap_story, se2_gate): sl = decode_slot(sl) if sl.pin_len: assert sl.pin[0:sl.pin_len].decode('ascii') == new_pin # simulator only - assert sl.tc_flags == xflags + if xflags is not None: + assert sl.tc_flags == xflags if xargs is not None: assert sl.tc_arg == xargs @@ -652,6 +653,67 @@ def test_ux_changing_pins(true_pin, repl, force_main_pin, goto_trick_menu, clear_all_tricks() +def test_trick_backups(goto_trick_menu, clear_all_tricks, repl, unit_test, + new_trick_pin, new_pin_confirmed, pick_menu_item, need_keypress): + + clear_all_tricks() + + # - make wallets of all duress types (x2 each) + # - plus a few simple ones + # - perform a backup and check result + + for n in range(1 or 8): + goto_trick_menu() + pin = '123-%04d'%n + new_trick_pin(pin, 'Duress Wallet', None) + item = 'BIP-85 Wallet #%d' % (n%4) if (n%4 != 0) else 'Legacy Wallet' + pick_menu_item(item) + need_keypress('y') + new_pin_confirmed(pin, item, None, None) + + for pin, op_mode, expect, _, xflags in [ + ('11-33', 'Just Reboot', 'Reboot when this PIN', False, TC_REBOOT), + ('11-55', 'Look Blank', 'Look and act like a freshly', False, TC_BLANK_WALLET), + ]: + new_trick_pin(pin, op_mode, expect) + new_pin_confirmed(pin, op_mode, xflags) + + # works, but not the best test + #unit_test('devtest/backups.py') + + bk = repl.exec('import backups; RV.write(backups.render_backup_contents())', raw=1) + + assert 'Coldcard backup file' in bk + + def decode_backup(txt): + import json + vals = dict() + for ln in txt.split('\n'): + if not ln: continue + if ln[0] == '#': continue + + k,v = ln.split(' = ', 1) + if k.startswith('duress_'): continue + if k.startswith('fw_'): continue + vals[k] = json.loads(v) + return vals + + # decode it + vals = decode_backup(bk) + + unit_test('devtest/clear_seed.py') + + repl.exec(f'import backups; backups.restore_from_dict_ll({vals!r})') + + # recover from recovery + repl.exec(f'import backups; pa.setup(pa.pin); pa.login(); from actions import goto_top_menu; goto_top_menu()') + + bk2 = repl.exec('import backups; RV.write(backups.render_backup_contents())', raw=1) + assert 'Traceback' not in bk2 + + vals2 = decode_backup(bk2) + assert vals == vals2 + # TODO # - make trick and do login, check arrives right state?