From 22fd6b4010fb3970af513b4535f681043e33126e Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Thu, 13 Jul 2023 11:13:09 +0200 Subject: [PATCH] BIP39 passphrase as ephemeral seed; Lock Down Seed for all ephemeral; BIP-39 wallet backup (cherry picked from commit e5d1782b9d8ad8069a4006bdce630966633c880a) --- docs/backup-files.md | 11 + releases/ChangeLog.md | 8 + shared/actions.py | 93 +++++-- shared/auth.py | 7 +- shared/backups.py | 51 +++- shared/drv_entro.py | 11 +- shared/flow.py | 9 +- shared/nvstore.py | 14 +- shared/pincodes.py | 20 +- shared/pwsave.py | 4 +- shared/seed.py | 77 +++--- shared/stash.py | 52 ++-- testing/conftest.py | 99 +++++++- testing/devtest/clear_seed.py | 2 +- testing/devtest/set_encoded_secret.py | 1 - testing/devtest/set_raw_secret.py | 1 - testing/devtest/set_seed.py | 1 - testing/devtest/set_tprv.py | 1 - testing/test_bip39pw.py | 173 ++++++++++--- testing/test_ephemeral.py | 349 +++++++++++++++++--------- testing/test_multisig.py | 28 ++- testing/test_pwsave.py | 11 +- testing/test_se2.py | 7 +- testing/test_sign.py | 2 +- testing/test_ux.py | 144 +++++------ 25 files changed, 782 insertions(+), 394 deletions(-) diff --git a/docs/backup-files.md b/docs/backup-files.md index 3bce29b7..81b146c1 100644 --- a/docs/backup-files.md +++ b/docs/backup-files.md @@ -38,6 +38,17 @@ a single file, which is a simple text file and easy to read. Before version 4.0.0, this text file was always called `ckcc-backup.txt`, but the filename is now picked randomly. +## BIP39 Passphrase + +If BIP39 passphrase is active the default behavior is to back-up +main wallet - not BIP39 passphrase wallet. From version `5.2.0` +users can choose to back-up also BIP39 passphrase wallet. + +## Ephemeral Seeds + +If ephemeral seed is active the default behavior is to always +back-up ephemeral wallet instead of the main wallet. + ## Limitations - The archive file names are not encrypted. You can see there is a single diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index 25ac73f3..1ee59260 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -1,11 +1,19 @@ ## 5.2.0 - 2023-09-21 +- New Feature: `Lock Down Seed` now works with every ephemeral secret (not just BIP39 passphrase) +- New Feature: BIP-39 Passphrase can now be added to word based Ephemeral Seeds +- New Feature: Add ability to back-up BIP39 Passphrase wallet - Enhancement: Shortcut to `Batch Sign PSBT` via `Ready To Sign` -> `Press (9)` - Enhancement: Old plausible deniability feature on fresh COLDCARD removed. Only needed for Mk 2-3 where SPI flash was external chip, easily observed, but now that's different. New simpler and less storage wasteful plausible deniability. - Enhancement: Remove obsolete Mk2/Mk3 code-paths from master branch +- Enhancement: BIP39 Passphrase is now internally handled as an ephemeral secret. + Ability to see BIP-39 Passphrase after wallet is active via `View Seed Words` + was removed as a consequence of this change. +- Enhancement: Showing secrets now also display extended private key for BIP-39 + passphrase wallets. ## 5.1.4 - 2023-09-08 diff --git a/shared/actions.py b/shared/actions.py index 36096330..3735ccae 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -108,8 +108,7 @@ Extended Master Key: if stash.bip39_passphrase: msg += '\nBIP-39 passphrase is in effect.\n' - - if pa.tmp_value: + elif pa.tmp_value: msg += '\nEphemeral seed is in effect.\n' bn = callgate.get_bag_number() @@ -555,22 +554,42 @@ def new_from_dice(menu, label, item): import seed return seed.new_from_dice(item.arg) -async def convert_bip39_to_bip32(*a): - import seed, stash +async def convert_ephemeral_to_master(*a): + import seed + from pincodes import pa + from stash import bip39_passphrase - if not await ux_confirm('''This operation computes the extended master private key using your BIP-39 seed words and passphrase, and then saves the resulting value (xprv) as the wallet secret. + if not pa.tmp_value: + await ux_show_story('You do not have an active ephemeral seed (including BIP-39 passphrase)' + ' right now, so this command does little except forget the seed words.' + ' It does not enhance security in any way.') + return -The seed words themselves are erased forever, but effectively there is no other change. If a BIP-39 passphrase is currently in effect, its value is captured during this process and will be 'in effect' going forward, but the passphrase itself is erased and unrecoverable. The resulting wallet cannot be used with any other passphrase. + words = settings.get("words", True) + msg = 'Convert currently used ' + msg += 'BIP-39 passphrase ' if bip39_passphrase else 'ephemeral seed ' + msg += 'to main seed. ' + if words or bip39_passphrase: + msg += 'Main seed words themselves are erased forever, ' + else: + msg += 'Main seed is erased forever, ' + + msg += 'but effectively there is no other change. ' + + if bip39_passphrase: + msg += ('BIP-39 passphrase is currently in effect, its value ' + 'is captured during this process and will be in effect ' + 'going forward, but the passphrase itself is erased ' + 'and unrecoverable. ') + if not words: + msg += 'The resulting wallet cannot be used with any other passphrase. ' + + msg += 'A reboot is part of this process. PIN code, and funds are not affected.' + if not await ux_confirm(msg): -A reboot is part of this process. PIN code, and funds are not affected. -'''): return await ux_aborted() - if not stash.bip39_passphrase: - if not await ux_confirm('''You do not have a BIP-39 passphrase set right now, so this command does little except forget the seed words. It does not enhance security.'''): - return - - await seed.remember_bip39_passphrase() + await seed.remember_ephemeral_seed() settings.save() @@ -612,8 +631,9 @@ consequences.''', escape='4') def render_master_secrets(mode, raw, node): # Render list of words, or XPRV / master secret to text. - import stash + import stash, chains + c = chains.current_chain() qr_alnum = False if mode == 'words': @@ -628,12 +648,14 @@ def render_master_secrets(mode, raw, node): msg = 'Seed words (%d):\n' % len(words) msg += '\n'.join('%2d: %s' % (i+1, w) for i,w in enumerate(words)) - pw = stash.bip39_passphrase - if pw: - msg += '\n\nBIP-39 Passphrase:\n%s' % pw + if stash.bip39_passphrase: + msg += '\n\nBIP-39 Passphrase:\n *****' + if node: + msg += '\n\nSeed+Passphrase:\n%s' % c.serialize_private(node) + + elif mode == 'xprv': - import chains - msg = chains.current_chain().serialize_private(node) + msg = c.serialize_private(node) qr = msg elif mode == 'master': @@ -648,21 +670,40 @@ def render_master_secrets(mode, raw, node): async def view_seed_words(*a): import stash - if not await ux_confirm('''The next screen will show the seed words (and if defined, your BIP-39 passphrase).\n\nAnyone with knowledge of those words can control all funds in this wallet.''' ): + if not await ux_confirm('The next screen will show the seed words' + ' (and if defined, your BIP-39 passphrase).' + '\n\nAnyone with knowledge of those words ' + 'can control all funds in this wallet.'): return from glob import dis dis.fullscreen("Wait...") dis.busy_bar(True) - with stash.SensitiveValues() as sv: + # preserve old UI where we show words + passphrase + # instead of just calculated seed + passphrase = extended privkey + # new: calculated xprv is now also shown for BIP39 passphrase wallet + raw = mode = None + if stash.bip39_passphrase: + # get main secret - bypass tmp + with stash.SensitiveValues(bypass_tmp=True) as sv: + if not sv.deltamode: + assert sv.mode == "words" + raw = sv.raw[:] + mode = sv.mode + + stash.SensitiveValues.clear_cache() + + with stash.SensitiveValues(bypass_tmp=False) as sv: if sv.deltamode: # give up and wipe self rather than show true seed values. import callgate callgate.fast_wipe() dis.busy_bar(False) - msg, qr, qr_alnum = render_master_secrets(sv.mode, sv.raw, sv.node) + msg, qr, qr_alnum = render_master_secrets(mode or sv.mode, + raw or sv.raw, + sv.node) msg += '\n\nPress (1) to view as QR Code.' @@ -674,8 +715,9 @@ async def view_seed_words(*a): continue break - stash.blank_object(qr) - stash.blank_object(msg) + stash.blank_object(qr) + stash.blank_object(msg) + stash.blank_object(raw) async def damage_myself(): # called when it's time to disable ourselves due to various @@ -1709,13 +1751,14 @@ async def ready2sign(*a): # - if no card, check virtual disk for PSBT # - if still nothing, then talk about USB connection import stash + from pincodes import pa from glob import NFC # just check if we have candidates, no UI choices = await file_picker(None, suffix='psbt', min_size=50, max_size=MAX_TXN_LEN, taster=is_psbt) - if stash.bip39_passphrase: + if pa.tmp_value: title = '[%s]' % xfp2str(settings.get('xfp')) else: title = None diff --git a/shared/auth.py b/shared/auth.py index 37b537c6..8741f81b 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -1220,7 +1220,7 @@ Press (2) to view the provided passphrase.\n\nOK to continue, X to cancel.''' % from seed import set_bip39_passphrase # full screen message shown: "Working..." - set_bip39_passphrase(self._pw) + await set_bip39_passphrase(self._pw, summarize_ux=False) self.result = settings.get('xpub') @@ -1233,8 +1233,9 @@ Press (2) to view the provided passphrase.\n\nOK to continue, X to cancel.''' % if self.result: new_xfp = settings.get('xfp') - await ux_show_story('''Above is the master key fingerprint of the current wallet.''', - title="[%s]" % xfp2str(new_xfp)) + await ux_show_story('Above is the master key fingerprint ' + 'of the current wallet.', + title="[%s]" % xfp2str(new_xfp)) def start_bip39_passphrase(pw): diff --git a/shared/backups.py b/shared/backups.py index b294599b..6f43945a 100644 --- a/shared/backups.py +++ b/shared/backups.py @@ -18,12 +18,12 @@ num_pw_words = const(12) # max size we expect for a backup data file (encrypted or cleartext) MAX_BACKUP_FILE_SIZE = const(10000) # bytes -def render_backup_contents(): +def render_backup_contents(bypass_tmp=False): # simple text format: # key = value # or #comments # but value is JSON - + current_tmp = None rv = StringIO() def COMMENT(val=None): @@ -41,7 +41,7 @@ def render_backup_contents(): COMMENT('Private key details: ' + chain.name) - with stash.SensitiveValues(bypass_pw=True) as sv: + with stash.SensitiveValues(bypass_tmp=bypass_tmp) as sv: if sv.deltamode: # die rather than give up our secrets import callgate @@ -72,6 +72,15 @@ def render_backup_contents(): COMMENT(path) for k,v in pairs: ADD(k, v) + + if bypass_tmp: + current_tmp = pa.tmp_value[:] + pa.tmp_value = None + # we also need correct settings from main seed + nv = stash.SecretStash.encode(seed_phrase=sv.raw) + settings.set_key(nv) + settings.load() + stash.blank_object(nv) COMMENT('Firmware version (informational)') date, vers, timestamp = version.get_mpy_version()[0:3] @@ -98,6 +107,13 @@ def render_backup_contents(): rv.write('\n# EOF\n') + if bypass_tmp: + # go back to tmp secret and its settings + stash.SensitiveValues.clear_cache() + pa.tmp_value = current_tmp + settings.set_key() + settings.load() + return rv.getvalue() def restore_from_dict_ll(vals): @@ -217,11 +233,26 @@ async def restore_from_dict(vals): async def make_complete_backup(fname_pattern='backup.7z', write_sflash=False): + from stash import bip39_passphrase + words = None skip_quiz = False + bypass_tmp = False - if pa.tmp_value: - if not await ux_confirm("An ephemeral seed is in effect, so backup will be of that seed."): + if bip39_passphrase and pa.tmp_value: + # this is a BIP39 password ephemeral wallet + msg = ("BIP39 passphrase is in effect. Backup ignores passphrases " + "and produces backup of main seed. Press OK to back-up main wallet," + " press (2) to back-up BIP39 passphrase wallet " + "(extended private key created via seed + pass)") + ch = await ux_show_story(msg, escape="2") + if ch == "x": return + if ch == "y": + bypass_tmp = True + + elif pa.tmp_value: + if not await ux_confirm("An ephemeral seed is in effect, " + "so backup will be of that seed."): return stored_words = settings.get('bkpw', None) @@ -278,16 +309,18 @@ async def make_complete_backup(fname_pattern='backup.7z', write_sflash=False): settings.remove_key('bkpw') settings.save() - return await write_complete_backup(words, fname_pattern, write_sflash=write_sflash) + return await write_complete_backup(words, fname_pattern, write_sflash=write_sflash, + bypass_tmp=bypass_tmp) -async def write_complete_backup(words, fname_pattern, write_sflash=False, allow_copies=True): +async def write_complete_backup(words, fname_pattern, write_sflash=False, + allow_copies=True, bypass_tmp=False): # Just do the writing from glob import dis from files import CardSlot # Show progress: dis.fullscreen('Encrypting...' if words else 'Generating...') - body = render_backup_contents().encode() + body = render_backup_contents(bypass_tmp=bypass_tmp).encode() gc.collect() @@ -637,7 +670,7 @@ async def clone_write_data(*a): fname = b2a_hex(my_pubkey).decode() + '-ccbk.7z' - await write_complete_backup(words, fname, allow_copies=False) + await write_complete_backup(words, fname, allow_copies=False, bypass_tmp=True) await ux_show_story("Done.\n\nTake this MicroSD card back to other Coldcard and continue from there.") diff --git a/shared/drv_entro.py b/shared/drv_entro.py index c71eb9b5..51e39318 100644 --- a/shared/drv_entro.py +++ b/shared/drv_entro.py @@ -18,6 +18,7 @@ from utils import chunk_writer BIP85_PWD_LEN = 21 async def drv_entro_start(*a): + from pincodes import pa # UX entry ch = await ux_show_story('''\ @@ -36,8 +37,14 @@ so the other wallet is effectively segregated from the Coldcard and yet \ still backed-up.''') if ch != 'y': return - if stash.bip39_passphrase: - if not await ux_confirm('''You have a BIP-39 passphrase set right now and so that will become wrapped into the new secret.'''): + if pa.tmp_value: + if stash.bip39_passphrase: + msg = ('You have a BIP-39 passphrase set right now ' + 'and so it will be wrapped into the new secret.') + else: + msg = 'You have an ephemeral seed - deriving from ephemeral.' + + if not await ux_confirm(msg): return choices = [ '12 words', '18 words', '24 words', 'WIF (privkey)', diff --git a/shared/flow.py b/shared/flow.py index 0c5cc78a..18f70e45 100644 --- a/shared/flow.py +++ b/shared/flow.py @@ -48,6 +48,11 @@ def se2_and_real_secret(): from pincodes import pa return (not pa.is_secret_blank()) and (not pa.tmp_value) +def bip39_passphrase_active(): + from stash import bip39_passphrase + from pincodes import pa + return settings.get('words', True) or (bip39_passphrase and pa.tmp_value) + HWTogglesMenu = [ ToggleMenuItem('USB Port', 'du', ['Default On', 'Disable USB'], invert=True, @@ -206,7 +211,7 @@ SeedFunctionsMenu = [ MenuItem('View Seed Words', f=view_seed_words), # text is a little wrong sometimes, rare MenuItem('Seed XOR', menu=SeedXORMenu), MenuItem("Destroy Seed", f=clear_seed), - MenuItem('Lock Down Seed', f=convert_bip39_to_bip32), + MenuItem('Lock Down Seed', f=convert_ephemeral_to_master), ] DangerZoneMenu = [ @@ -310,7 +315,7 @@ EmptyWallet = [ NormalSystem = [ # xxxxxxxxxxxxxxxx MenuItem('Ready To Sign', f=ready2sign), - MenuItem('Passphrase', f=start_b39_pw, predicate=lambda: settings.get('words', True)), + MenuItem('Passphrase', f=start_b39_pw, predicate=bip39_passphrase_active), MenuItem('Start HSM Mode', f=start_hsm_menu_item, predicate=hsm_policy_available), MenuItem("Address Explorer", f=address_explore), MenuItem('Type Passwords', f=password_entry, predicate=lambda: settings.get("emu", False) and has_secrets()), diff --git a/shared/nvstore.py b/shared/nvstore.py index 38cf618e..1d41288d 100644 --- a/shared/nvstore.py +++ b/shared/nvstore.py @@ -98,7 +98,6 @@ class SettingsObject: self.nvram_key = b'\0'*32 self.capacity = 0 self.current = self.default_values() - self.overrides = {} # volatile overide values self.load(dis) @@ -256,7 +255,6 @@ class SettingsObject: # and pick the newest one (in unlikely case of dups) # reset self.current.clear() - self.overrides.clear() self.my_pos = None self.is_dirty = 0 self.capacity = 0 @@ -311,10 +309,7 @@ class SettingsObject: self.current['chain'] = 'XTN' def get(self, kn, default=None): - if kn in self.overrides: - return self.overrides.get(kn) - else: - return self.current.get(kn, default) + return self.current.get(kn, default) def changed(self): self.is_dirty += 1 @@ -330,9 +325,6 @@ class SettingsObject: self.current[kn] = v self.changed() - def put_volatile(self, kn, v): - self.overrides[kn] = v - set = put def remove_key(self, kn): @@ -359,8 +351,7 @@ class SettingsObject: rk = [k for k in self.current if k[0] != '_'] for k in rk: del self.current[k] - - self.overrides.clear() + self.changed() async def write_out(self): @@ -419,7 +410,6 @@ class SettingsObject: # act blank too, just in case. self.current.clear() - self.overrides.clear() self.is_dirty = 0 self.capacity = 0 diff --git a/shared/pincodes.py b/shared/pincodes.py index 84b7df6f..8c474a80 100644 --- a/shared/pincodes.py +++ b/shared/pincodes.py @@ -369,8 +369,8 @@ class PinAttempt: # - call new_main_secret() when main secret changes! # - is_secret_blank and is_successful may be wrong now, re-login to get again - def fetch(self, duress_pin=None, spare_num=0): - if self.tmp_value: + def fetch(self, duress_pin=None, spare_num=0, bypass_tmp=False): + if self.tmp_value and not bypass_tmp: # must make a copy here, and must be mutable instance so not reused if spare_num: return bytearray(AE_SECRET_LEN) @@ -413,12 +413,15 @@ class PinAttempt: self.roundtrip(7, fw_upgrade=(start, length)) # not-reached - def new_main_secret(self, raw_secret, chain=None): + def new_main_secret(self, raw_secret, chain=None, bip39pw=''): # Main secret has changed: reset the settings+their key, # and capture xfp/xpub - from glob import settings, NFC + from glob import settings import stash stash.SensitiveValues.clear_cache() + + stash.bip39_passphrase = bool(bip39pw) + # capture values we have already old_values = dict(settings.current) @@ -434,7 +437,7 @@ class PinAttempt: # does not call settings.save() but caller should! - def tmp_secret(self, encoded, chain=None): + def tmp_secret(self, encoded, chain=None, bip39pw=''): # Use indicated secret and stop using the SE; operate like this until reboot val = bytes(encoded + bytes(AE_SECRET_LEN - len(encoded))) if self.tmp_value == val: @@ -445,14 +448,9 @@ class PinAttempt: # We're no longer blank. hard to say about duress secret and stuff tho self.state_flags = PA_SUCCESSFUL - # Clear bip-39 secret, not applicable anymore. - import stash - stash.bip39_passphrase = '' - stash.SensitiveValues.clear_cache() - # Copies system settings to new encrypted-key value, calculates # XFP, XPUB and saves into that, and starts using them. - self.new_main_secret(self.tmp_value, chain=chain) + self.new_main_secret(self.tmp_value, chain=chain, bip39pw=bip39pw) return True def trick_request(self, method_num, data): diff --git a/shared/pwsave.py b/shared/pwsave.py index 522e39b0..065471ac 100644 --- a/shared/pwsave.py +++ b/shared/pwsave.py @@ -25,7 +25,7 @@ class PassphraseSaver: try: salt = card.get_id_hash() - with stash.SensitiveValues(bypass_pw=True) as sv: + with stash.SensitiveValues(bypass_tmp=True) as sv: self.key = bytearray(sv.encryption_key(salt)) except: @@ -132,7 +132,7 @@ class PassphraseSaver: async def doit(menu, idx, item): # apply the password immediately and drop them at top menu pw, expect_xfp = item.arg - set_bip39_passphrase(pw) + await set_bip39_passphrase(pw) from glob import settings from utils import xfp2str diff --git a/shared/seed.py b/shared/seed.py index 19f2bd54..b8b8499d 100644 --- a/shared/seed.py +++ b/shared/seed.py @@ -11,7 +11,7 @@ # - 'abandon' * 11 + 'about' # from menu import MenuItem, MenuSystem -from utils import xfp2str, parse_extended_key +from utils import xfp2str, parse_extended_key, swab32 import ngu, uctypes, bip39, random, version from uhashlib import sha256 from ux import ux_show_story, the_ux, ux_dramatic_pause, ux_confirm @@ -405,17 +405,18 @@ async def new_from_dice(nwords): # send them to home menu, now with a wallet enabled goto_top_menu(first_time=True) -async def set_ephemeral_seed(encoded, chain=None): - applied = pa.tmp_secret(encoded, chain=chain) +async def set_ephemeral_seed(encoded, chain=None, summarize_ux=True, bip39pw=''): + applied = pa.tmp_secret(encoded, chain=chain, bip39pw=bip39pw) dis.progress_bar_show(1) - xfp = settings.get("xfp", "") + xfp = settings.get("xfp", None) if xfp: - xfp = "[" + xfp2str(xfp) + "]\n" + xfp = "[" + xfp2str(xfp) + "]" if not applied: - await ux_show_story("%sEphemeral master key already in use." % xfp) + await ux_show_story(title=xfp, msg="Ephemeral master key already in use.") return - await ux_show_story("%sNew ephemeral master key in effect until next power down." % xfp) + if summarize_ux: + await ux_show_story(title=xfp, msg="New ephemeral master key in effect until next power down.") async def set_ephemeral_seed_words(words): dis.progress_bar_show(0.1) @@ -579,40 +580,38 @@ def set_seed_value(words=None, encoded=None, chain=None): finally: dis.busy_bar(False) -def set_bip39_passphrase(pw): - # apply bip39 passphrase for now (volatile) - # takes a bit, so show something +async def calc_bip39_passphrase(pw, bypass_tmp=False): from glob import dis dis.fullscreen("Working...") - - # set passphrase import stash - stash.bip39_passphrase = pw - - # capture updated XFP - with stash.SensitiveValues() as sv: + # generate secret + with stash.SensitiveValues(bip39pw=pw, bypass_tmp=bypass_tmp) as sv: # can't do it without original seed words (late, but caller has checked) assert sv.mode == 'words' + nv = SecretStash.encode(xprv=sv.node) + xfp = swab32(sv.node.my_fp()) + return nv, xfp - sv.capture_xpub() - +async def set_bip39_passphrase(pw, summarize_ux=True): + nv, _ = await calc_bip39_passphrase(pw) + await set_ephemeral_seed(nv, summarize_ux=summarize_ux, bip39pw=pw) # Might need to bounce the USB connection, because our pubkey has changed, # altho if they have already picked a shared session key, no need, and # would only affect MitM test, which has already been done. -async def remember_bip39_passphrase(): +async def remember_ephemeral_seed(): # Compute current xprv and switch to using that as root secret. import stash from glob import dis dis.fullscreen('Check...') - with stash.SensitiveValues() as sv: - nv = SecretStash.encode(xprv=sv.node) - - # Important: won't write new XFP to nvram if pw still set - stash.bip39_passphrase = '' + if sv.mode == "xprv": + nv = SecretStash.encode(xprv=sv.node) + else: + assert sv.mode == "words" + nv = SecretStash.encode(seed_phrase=sv.raw) dis.fullscreen('Saving...') pa.change(new_secret=nv) @@ -866,23 +865,33 @@ class PassphraseMenu(MenuSystem): async def done_apply(self, *a): # apply the passphrase. # - important to work on empty string here too. - from stash import bip39_passphrase - old_pw = str(bip39_passphrase) + import stash + from glob import settings + from pincodes import pa - set_bip39_passphrase(pp_sofar) + nv, xfp = await calc_bip39_passphrase(pp_sofar, bypass_tmp=True) - xfp = settings.get('xfp') + msg = ('Above is the master key fingerprint of the new wallet. ' + 'Press X to abort and keep editing passphrase, ' + 'OK to use the new wallet, (1) to use and save to MicroSD') - msg = '''Above is the master key fingerprint of the new wallet. + msg1 = "" + if pa.tmp_value and settings.get("words", True): + # we have ephemeral seed but can add passphrase to it as it is word based + msg1 = (", or press (2) to add passphrase to the current " + "active ephemeral seed instead of the main seed.") -Press X to abort and keep editing passphrase, OK to use the new wallet, or 1 to use and save to MicroSD''' - - ch = await ux_show_story(msg, title="[%s]" % xfp2str(xfp), escape='1') + ch = await ux_show_story(msg + msg1, title="[%s]" % xfp2str(xfp), escape='12') if ch == 'x': - # go back! - set_bip39_passphrase(old_pw) return + if ch == "2": + stash.SensitiveValues.clear_cache() + nv, xfp = await calc_bip39_passphrase(pp_sofar, bypass_tmp=False) + ch = await ux_show_story(msg, title="[%s]" % xfp2str(xfp), escape='1') + if ch == "x": return + + await set_ephemeral_seed(nv, summarize_ux=False, bip39pw=pp_sofar) if ch == '1': await PassphraseSaver().append(xfp, pp_sofar) diff --git a/shared/stash.py b/shared/stash.py index 130d6780..0a97aee2 100644 --- a/shared/stash.py +++ b/shared/stash.py @@ -128,7 +128,8 @@ class SecretStash: return 'master', ms, hd # optional global value: user-supplied passphrase to salt BIP-39 seed process -bip39_passphrase = '' +# just a boolean flag from version 5.2.0 +bip39_passphrase = False CACHE_CHECK_RATE = const(10*1000) # 10 seconds CACHE_MAX_LIFE = const(60*1000) # one minute @@ -136,16 +137,14 @@ CACHE_MAX_LIFE = const(60*1000) # one minute class SensitiveValues: # be a context manager, and holder of secrets in-memory - # class-level cache, key is bip39 pass - _cache = {} + # class-level cache _cache_secret = None _cache_used = None - def __init__(self, secret=None, bypass_pw=False): + def __init__(self, secret=None, bip39pw='', bypass_tmp=False): self.spots = [] - # backup during volatile bip39 encryption: do not use passphrase - self._bip39pw = '' if bypass_pw else str(bip39_passphrase) + self._bip39pw = bip39pw if secret is not None: # sometimes we already know the secret @@ -162,24 +161,20 @@ class SensitiveValues: raise ValueError('no secrets yet') self.deltamode = pa.is_deltamode() - if self._bip39pw in self._cache: - # cache hit + if self._cache_secret and not bypass_tmp: + # they are using new BIP39 passphrase but we already have raw secret self.secret = bytearray(self._cache_secret) - self.mode, r, n = self._cache[self._bip39pw] - self.raw = bytearray(r) - self.node = n.copy() - self.__class__._cache_used = utime.ticks_ms() else: - if self._cache_secret: - # they are using new BIP39 passphrase but we already have raw secret - self.secret = bytearray(self._cache_secret) - else: - # slow: read from secure element(s) - self.secret = pa.fetch() + # slow: read from secure element(s) + self.secret = pa.fetch(bypass_tmp=bypass_tmp) - # slow: do bip39 key stretching (typically) - self.mode, self.raw, self.node = SecretStash.decode(self.secret, self._bip39pw) + # slow: do bip39 key stretching (typically) + self.mode, self.raw, self.node = SecretStash.decode(self.secret, self._bip39pw) + if not bypass_tmp: + # DO NOT save to cache if we are bypassing tmp + # we mostly just need it for some specific + # operation after which we go back to tmp self.save_to_cache() self.spots.append(self.secret) @@ -197,12 +192,6 @@ class SensitiveValues: # - will be called after 2 minutes of idle keypad blank_object(cls._cache_secret) cls._cache_secret = None - - for _,raw,node in cls._cache.values(): - blank_object(raw) - blank_object(node) - - cls._cache.clear() cls._cache_used = None def save_to_cache(self): @@ -212,7 +201,6 @@ class SensitiveValues: else: assert SensitiveValues._cache_secret == self.secret - SensitiveValues._cache[self._bip39pw] = ( self.mode, bytearray(self.raw), self.node.copy() ) SensitiveValues._cache_used = utime.ticks_ms() call_later_ms(CACHE_CHECK_RATE, self.cache_check) @@ -290,14 +278,8 @@ class SensitiveValues: xfp = swab32(self.node.my_fp()) xpub = self.chain.serialize_public(self.node) - if self._bip39pw: - settings.put_volatile('xfp', xfp) - settings.put_volatile('xpub', xpub) - else: - settings.overrides.clear() - settings.put('xfp', xfp) - settings.put('xpub', xpub) - + settings.put('xfp', xfp) + settings.put('xpub', xpub) settings.put('chain', self.chain.ctype) # calc num words in seed, or zero diff --git a/testing/conftest.py b/testing/conftest.py index 44ef5099..633ed10d 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -1,6 +1,7 @@ # (c) Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC. # import pytest, time, sys, random, re, ndef, os, glob, hashlib, json, bech32 +from subprocess import check_output from ckcc.protocol import CCProtocolPacker from helpers import B2A, U2SAT, prandom, taptweak from msg import verify_message @@ -706,7 +707,7 @@ def set_master_key(sim_exec, sim_execfile, simulator, reset_seed_words): reset_seed_words() @pytest.fixture(scope="function") -def set_xfp(sim_exec, sim_execfile, simulator, reset_seed_words): +def set_xfp(sim_exec): # set the XFP, without really knowing the private keys # - won't be able to sign, but should accept PSBT for signing @@ -716,11 +717,12 @@ def set_xfp(sim_exec, sim_execfile, simulator, reset_seed_words): import struct need_xfp, = struct.unpack("= 4 + assert "same words next time" in body + assert "Press (1) to save" in body + need_keypress('x') + time.sleep(.01) + assert get_setting('bkpw', 'xxx') == 'xxx' + title, story = cap_story() + assert "Backup file written:" in story + fn = story.split("\n\n")[1] + assert fn.endswith(".7z") + verify_backup_file(fn) + contents = check_and_decrypt_backup(fn, words) + assert "mnemonic" not in contents + assert simulator_fixed_words not in contents + assert simulator_fixed_xprv not in contents + assert target == contents + seed = Mnemonic.to_seed(simulator_fixed_words, passphrase=passphrase) + expect = BIP32Node.from_master_secret(seed, netcode="XTN") + esk = expect.hwif(as_private=True) + epk = expect.hwif(as_private=False) + target_esk = None + target_epk = None + for line in contents.split("\n"): + if line.startswith("xprv ="): + target_esk = line.split("=")[-1].strip().replace('"', '') + if line.startswith("xpub ="): + target_epk = line.split("=")[-1].strip().replace('"', '') + assert target_epk == epk + assert target_esk == esk + + restore_backup_cs(fn, words) + # EOF diff --git a/testing/test_ephemeral.py b/testing/test_ephemeral.py index daef8ea6..0c75ed28 100644 --- a/testing/test_ephemeral.py +++ b/testing/test_ephemeral.py @@ -7,6 +7,9 @@ from constants import simulator_fixed_tpub from ckcc.protocol import CCProtocolPacker from txn import fake_txn from test_ux import word_menu_entry +from constants import simulator_fixed_words, simulator_fixed_xprv +from pycoin.key.BIP32Node import BIP32Node +from mnemonic import Mnemonic WORDLISTS = { @@ -33,8 +36,17 @@ def seed_story_to_words(story: str): @pytest.fixture -def ephemeral_seed_disabled(cap_menu): +def ephemeral_seed_disabled(sim_exec): def doit(): + rv = sim_exec('from pincodes import pa; RV.write(repr(pa.tmp_value))') + assert not eval(rv) + return doit + + +@pytest.fixture +def ephemeral_seed_disabled_ui(cap_menu): + def doit(): + # MUST be in ephemeral seed menu already time.sleep(0.1) menu = cap_menu() # no ephemeral seed chosen (yet) @@ -85,7 +97,7 @@ def get_identity_story(goto_home, pick_menu_item, cap_story): @pytest.fixture def goto_eph_seed_menu(goto_home, pick_menu_item, cap_story, need_keypress): - def doit(): + def _doit(): goto_home() pick_menu_item("Advanced/Tools") pick_menu_item("Ephemeral Seed") @@ -95,6 +107,14 @@ def goto_eph_seed_menu(goto_home, pick_menu_item, cap_story, need_keypress): assert "temporary secret stored solely in device RAM" in story assert "Press (4) to prove you read to the end of this message and accept all consequences." in story need_keypress("4") # understand consequences + + def doit(): + try: + _doit() + except: + time.sleep(.1) + _doit() + return doit @@ -104,8 +124,8 @@ def verify_ephemeral_secret_ui(cap_story, need_keypress, cap_menu, dev, fake_txn get_seed_value_ux): def doit(mnemonic=None, xpub=None, expected_xfp=None): time.sleep(0.3) - _, story = cap_story() - in_effect_xfp = story[1:9] + title, story = cap_story() + in_effect_xfp = title[1:-1] if expected_xfp is not None: assert in_effect_xfp == expected_xfp assert "key in effect until next power down." in story @@ -146,42 +166,115 @@ def verify_ephemeral_secret_ui(cap_story, need_keypress, cap_menu, dev, fake_txn return doit +@pytest.fixture +def generate_ephemeral_words(goto_eph_seed_menu, pick_menu_item, + need_keypress, cap_story, + ephemeral_seed_disabled_ui): + def doit(num_words, dice=False, from_main=False): + goto_eph_seed_menu() + if from_main: + ephemeral_seed_disabled_ui() + + pick_menu_item("Generate Words") + if not dice: + pick_menu_item(f"{num_words} Words") + time.sleep(0.1) + else: + pick_menu_item(f"{num_words} Word Dice Roll") + for ch in '123456yy': + need_keypress(ch) + + time.sleep(0.2) + title, story = cap_story() + assert f"Record these {num_words} secret words!" in story + assert "Press (6) to skip word quiz" in story + + # filter those that starts with space, number and colon --> actual words + e_seed_words = seed_story_to_words(story) + assert len(e_seed_words) == num_words + + need_keypress("6") # skip quiz + need_keypress("y") # yes - I'm sure + time.sleep(0.1) + need_keypress("4") # understand consequences + return e_seed_words + + return doit + + +@pytest.fixture +def import_ephemeral_xprv(microsd_path, virtdisk_path, goto_eph_seed_menu, + pick_menu_item, need_keypress, cap_story, + nfc_write_text, ephemeral_seed_disabled_ui): + def doit(way, extended_key=None, testnet=True, from_main=False): + from pycoin.key.BIP32Node import BIP32Node + fname = "ek.txt" + if extended_key is None: + node = BIP32Node.from_master_secret(os.urandom(32), netcode="XTN" if testnet else "BTC") + ek = node.hwif(as_private=True) + '\n' + if way == "sd": + fpath = microsd_path(fname) + elif way == "vdisk": + fpath = virtdisk_path(fname) + if way != "nfc": + with open(fpath, "w") as f: + f.write(ek) + else: + node = BIP32Node.from_wallet_key(extended_key) + assert extended_key == node.hwif(as_private=True) + ek = extended_key + + if testnet: + assert "tprv" in ek + else: + assert "xprv" in ek + + goto_eph_seed_menu() + if from_main: + ephemeral_seed_disabled_ui() + + pick_menu_item("Import XPRV") + time.sleep(0.1) + _, story = cap_story() + if way == "sd": + if "Press (1) to import extended private key file from SD Card" in story: + need_keypress("1") + elif way == "nfc": + if "press (3) to import via NFC" not in story: + pytest.xfail("NFC disabled") + else: + need_keypress("3") + time.sleep(0.2) + nfc_write_text(ek) + time.sleep(0.3) + else: + # virtual disk + if "press (2) to import from Virtual Disk" not in story: + pytest.xfail("Vdisk disabled") + else: + need_keypress("2") + + if way != "nfc": + time.sleep(0.1) + _, story = cap_story() + assert "Select file containing the extended private key" in story + need_keypress("y") + pick_menu_item(fname) + + return node + + return doit + + @pytest.mark.parametrize("num_words", [12, 24]) @pytest.mark.parametrize("dice", [False, True]) -def test_ephemeral_seed_generate(num_words, pick_menu_item, cap_story, need_keypress, - reset_seed_words, goto_eph_seed_menu, dice, +def test_ephemeral_seed_generate(num_words, generate_ephemeral_words, dice, + reset_seed_words, goto_eph_seed_menu, ephemeral_seed_disabled, verify_ephemeral_secret_ui): - reset_seed_words() - try: - goto_eph_seed_menu() - except: - time.sleep(.1) - goto_eph_seed_menu() - ephemeral_seed_disabled() - pick_menu_item("Generate Words") - if not dice: - pick_menu_item(f"{num_words} Words") - time.sleep(0.1) - else: - pick_menu_item(f"{num_words} Word Dice Roll") - for ch in '123456yy': - need_keypress(ch) - - time.sleep(0.2) - title, story = cap_story() - assert f"Record these {num_words} secret words!" in story - assert "Press (6) to skip word quiz" in story - - # filter those that starts with space, number and colon --> actual words - e_seed_words = seed_story_to_words(story) - assert len(e_seed_words) == num_words - - need_keypress("6") # skip quiz - need_keypress("y") # yes - I'm sure - time.sleep(0.1) - need_keypress("4") # understand consequences + e_seed_words = generate_ephemeral_words(num_words=num_words, dice=dice, + from_main=True) verify_ephemeral_secret_ui(mnemonic=e_seed_words) @@ -198,11 +291,7 @@ def test_ephemeral_seed_import_words(nfc, truncated, num_words, cap_menu, pick_m words, expect_xfp = WORDLISTS[num_words] reset_seed_words() - try: - goto_eph_seed_menu() - except: - time.sleep(.1) - goto_eph_seed_menu() + goto_eph_seed_menu() ephemeral_seed_disabled() pick_menu_item("Import Words") @@ -244,11 +333,7 @@ def test_ephemeral_seed_import_tapsigner(way, retry, testnet, pick_menu_item, ca fname, backup_key_hex, node = tapsigner_encrypted_backup(way, testnet=testnet) - try: - goto_eph_seed_menu() - except: - time.sleep(.1) - goto_eph_seed_menu() + goto_eph_seed_menu() ephemeral_seed_disabled() pick_menu_item("Tapsigner Backup") @@ -302,11 +387,8 @@ def test_ephemeral_seed_import_tapsigner_fail(pick_menu_item, cap_story, fail, if fail == "garbage": with open(microsd_path(fname), "wb") as f: f.write(os.urandom(152)) - try: - goto_eph_seed_menu() - except: - time.sleep(.1) - goto_eph_seed_menu() + + goto_eph_seed_menu() ephemeral_seed_disabled() pick_menu_item("Tapsigner Backup") @@ -359,11 +441,7 @@ def test_ephemeral_seed_import_tapsigner_real(data, pick_menu_item, cap_story, m fpath = microsd_path(fname) shutil.copy(f"data/{fname}", fpath) reset_seed_words() - try: - goto_eph_seed_menu() - except: - time.sleep(.1) - goto_eph_seed_menu() + goto_eph_seed_menu() ephemeral_seed_disabled() pick_menu_item("Tapsigner Backup") @@ -390,63 +468,12 @@ def test_ephemeral_seed_import_tapsigner_real(data, pick_menu_item, cap_story, m @pytest.mark.parametrize("way", ["sd", "vdisk", "nfc"]) @pytest.mark.parametrize('retry', range(3)) @pytest.mark.parametrize("testnet", [True, False]) -def test_ephemeral_seed_import_xprv(way, retry, testnet, pick_menu_item, - cap_story, need_keypress, reset_seed_words, - goto_eph_seed_menu, nfc_write_text, microsd_path, - virtdisk_path, verify_ephemeral_secret_ui, - ephemeral_seed_disabled): +def test_ephemeral_seed_import_xprv(way, retry, testnet, reset_seed_words, + goto_eph_seed_menu, verify_ephemeral_secret_ui, + ephemeral_seed_disabled, import_ephemeral_xprv): reset_seed_words() - fname = "ek.txt" - from pycoin.key.BIP32Node import BIP32Node - node = BIP32Node.from_master_secret(os.urandom(32), netcode="XTN" if testnet else "BTC") - ek = node.hwif(as_private=True) + '\n' - if way =="sd": - fpath = microsd_path(fname) - elif way == "vdisk": - fpath = virtdisk_path(fname) - if way != "nfc": - with open(fpath, "w") as f: - f.write(ek) - if testnet: - assert "tprv" in ek - else: - assert "xprv" in ek - - try: - goto_eph_seed_menu() - except: - time.sleep(.1) - goto_eph_seed_menu() - ephemeral_seed_disabled() - pick_menu_item("Import XPRV") - time.sleep(0.1) - _, story = cap_story() - if way == "sd": - if "Press (1) to import extended private key file from SD Card" in story: - need_keypress("1") - elif way == "nfc": - if "press (3) to import via NFC" not in story: - pytest.xfail("NFC disabled") - else: - need_keypress("3") - time.sleep(0.2) - nfc_write_text(ek) - time.sleep(0.3) - else: - # virtual disk - if "press (2) to import from Virtual Disk" not in story: - pytest.xfail("Vdisk disabled") - else: - need_keypress("2") - - if way != "nfc": - time.sleep(0.1) - _, story = cap_story() - assert "Select file containing the extended private key" in story - need_keypress("y") - pick_menu_item(fname) - + node = import_ephemeral_xprv(way=way, testnet=testnet, from_main=True) verify_ephemeral_secret_ui(xpub=node.hwif()) @@ -455,11 +482,7 @@ def test_activate_current_tmp_secret(reset_seed_words, goto_eph_seed_menu, pick_menu_item, need_keypress, word_menu_entry): reset_seed_words() - try: - goto_eph_seed_menu() - except: - time.sleep(.1) - goto_eph_seed_menu() + goto_eph_seed_menu() ephemeral_seed_disabled() words, expected_xfp = WORDLISTS[12] @@ -469,15 +492,11 @@ def test_activate_current_tmp_secret(reset_seed_words, goto_eph_seed_menu, word_menu_entry(words.split()) time.sleep(0.3) - _, story = cap_story() + title, story = cap_story() assert "key in effect until next power down." in story - in_effect_xfp = story[1:9] + in_effect_xfp = title[1:-1] need_keypress("y") - try: - goto_eph_seed_menu() - except: - time.sleep(.1) - goto_eph_seed_menu() + goto_eph_seed_menu() pick_menu_item("Import Words") pick_menu_item(f"12 Words") @@ -485,10 +504,92 @@ def test_activate_current_tmp_secret(reset_seed_words, goto_eph_seed_menu, word_menu_entry(words.split()) time.sleep(0.3) - _, story = cap_story() + title, story = cap_story() assert "Ephemeral master key already in use" in story - already_used_xfp = story[1:9] + already_used_xfp = title[1:-1] assert already_used_xfp == in_effect_xfp == expected_xfp need_keypress("y") + +@pytest.mark.parametrize("stype", ["words12", "words24", "xprv"]) +def test_backup_ephemeral_wallet(stype, pick_menu_item, need_keypress, goto_home, + cap_story, pass_word_quiz, get_setting, + verify_backup_file, microsd_path, check_and_decrypt_backup, + sim_execfile, unit_test, word_menu_entry, cap_menu, + restore_backup_cs, generate_ephemeral_words, + import_ephemeral_xprv, reset_seed_words): + reset_seed_words() + goto_home() + if "words" in stype: + num_words = int(stype.replace("words", "")) + sec = generate_ephemeral_words(num_words, from_main=True) + else: + sec = import_ephemeral_xprv("sd", from_main=True) + + target = sim_execfile('devtest/get-secrets.py') + assert 'Error' not in target + need_keypress("y") + pick_menu_item("Advanced/Tools") + pick_menu_item("Backup") + pick_menu_item("Backup System") + time.sleep(.1) + title, story = cap_story() + assert "An ephemeral seed is in effect" in story + assert "so backup will be of that seed" in story + need_keypress("y") + time.sleep(.1) + title, story = cap_story() + if "Use same backup file password as last time?" in story: + need_keypress("x") + time.sleep(.1) + title, story = cap_story() + assert title == 'NO-TITLE' + assert 'Record this' in story + assert 'password:' in story + + words = [w[3:].strip() for w in story.split('\n') if w and w[2] == ':'] + assert len(words) == 12 + # pass the quiz! + count, title, body = pass_word_quiz(words) + assert count >= 4 + assert "same words next time" in body + assert "Press (1) to save" in body + need_keypress('x') + time.sleep(.01) + assert get_setting('bkpw', 'xxx') == 'xxx' + title, story = cap_story() + assert "Backup file written:" in story + fn = story.split("\n\n")[1] + assert fn.endswith(".7z") + verify_backup_file(fn) + contents = check_and_decrypt_backup(fn, words) + if "words" in stype: + assert "mnemonic" in contents + else: + assert "mnemonic" not in contents + assert simulator_fixed_words not in contents + assert simulator_fixed_xprv not in contents + assert target == contents + if "words" in stype: + words_str = " ".join(sec) + assert words_str in contents + seed = Mnemonic.to_seed(words_str) + expect = BIP32Node.from_master_secret(seed, netcode="XTN") + else: + expect = sec + + target_esk = None + target_epk = None + esk = expect.hwif(as_private=True) + epk = expect.hwif(as_private=False) + for line in contents.split("\n"): + if line.startswith("xprv ="): + target_esk = line.split("=")[-1].strip().replace('"', '') + if line.startswith("xpub ="): + target_epk = line.split("=")[-1].strip().replace('"', '') + assert target_epk == epk + assert target_esk == esk + + restore_backup_cs(fn, words) + # EOF diff --git a/testing/test_multisig.py b/testing/test_multisig.py index 0ddd63c6..7730bac5 100644 --- a/testing/test_multisig.py +++ b/testing/test_multisig.py @@ -6,7 +6,7 @@ # # py.test test_multisig.py -m ms_danger --ms-danger # -import time, pytest, os, random, json, shutil, pdb, io, base64 +import time, pytest, os, random, json, shutil, pdb, io, base64, struct from psbt import BasicPSBT, BasicPSBTInput, BasicPSBTOutput from ckcc.protocol import CCProtocolPacker, MAX_TXN_LEN from pprint import pprint @@ -87,14 +87,14 @@ def clear_ms(unit_test): unit_test('devtest/wipe_ms.py') return doit -@pytest.fixture() -def make_multisig(): +@pytest.fixture +def make_multisig(dev, sim_execfile): # make a multsig wallet, always with simulator as an element # default is BIP-45: m/45'/... (but no co-signer idx) # - but can provide str format for deriviation, use {idx} for cosigner idx - def doit(M, N, unique=0, deriv=None, chain="XTN"): + def doit(M, N, unique=0, deriv=None, dev_key=False, chain="XTN"): keys = [] for i in range(N-1): @@ -114,7 +114,14 @@ def make_multisig(): keys.append((xfp, pk, sub)) - pk = BIP32Node.from_wallet_key(simulator_fixed_tprv if chain == "XTN" else simulator_fixed_xprv) + if dev_key: + sk = sim_execfile('devtest/dump_private.py').strip() + pk = BIP32Node.from_wallet_key(sk) + xfp_bytes = pk.fingerprint() + xfp = swab32(struct.unpack('>I', xfp_bytes)[0]) + else: + pk = BIP32Node.from_wallet_key(simulator_fixed_tprv if chain == "XTN" else simulator_fixed_xprv) + xfp = simulator_fixed_xfp if not deriv: sub = pk.subkey(45, is_hardened=True, as_private=True) @@ -126,7 +133,7 @@ def make_multisig(): # some test cases are using bogus paths sub = pk - keys.append((simulator_fixed_xfp, pk, sub)) + keys.append((xfp, pk, sub)) return keys @@ -153,9 +160,12 @@ def offer_ms_import(cap_story, dev, need_keypress): @pytest.fixture def import_ms_wallet(dev, make_multisig, offer_ms_import, need_keypress): - def doit(M, N, addr_fmt=None, name=None, unique=0, accept=False, common=None, keys=None, do_import=True, derivs=None, - descriptor=False, int_ext_desc=False, chain="XTN"): - keys = keys or make_multisig(M, N, unique=unique, deriv=common or (derivs[0] if derivs else None), chain=chain) + def doit(M, N, addr_fmt=None, name=None, unique=0, accept=False, common=None, + keys=None, do_import=True, derivs=None, descriptor=False, + int_ext_desc=False, dev_key=False, chain="XTN"): + keys = keys or make_multisig(M, N, unique=unique, dev_key=dev_key, + deriv=common or (derivs[0] if derivs else None), + chain=chain) name = name or f'test-{M}-{N}' if not do_import: diff --git a/testing/test_pwsave.py b/testing/test_pwsave.py index 573e9d6c..9ffdb107 100644 --- a/testing/test_pwsave.py +++ b/testing/test_pwsave.py @@ -46,8 +46,8 @@ def get_to_pwmenu(cap_story, need_keypress, goto_home, pick_menu_item): '1aaa2aaa', 'ab'*25, ]) -def test_first_time(pws, need_keypress, cap_story, pick_menu_item, goto_home, enter_complex, cap_menu, get_to_pwmenu): - +def test_first_time(pws, need_keypress, cap_story, pick_menu_item, enter_complex, + cap_menu, get_to_pwmenu, reset_seed_words): try: os.unlink(SIM_FNAME) except: pass @@ -75,11 +75,11 @@ def test_first_time(pws, need_keypress, cap_story, pick_menu_item, goto_home, en time.sleep(.01) title, story = cap_story() xfp = title[1:-1] - assert '1 to use and save to MicroSD' in story + assert '(1) to use and save to MicroSD' in story need_keypress('1') xfps[pw] = xfp - + reset_seed_words() for n, pw in enumerate(uniq): get_to_pwmenu() @@ -103,7 +103,8 @@ def test_first_time(pws, need_keypress, cap_story, pick_menu_item, goto_home, en xfp = title[1:-1] assert xfp == xfps[uniq[n]] - need_keypress('y'); + need_keypress('y') + reset_seed_words() def test_crypto_unittest(sim_eval, sim_exec, simulator): diff --git a/testing/test_se2.py b/testing/test_se2.py index a023b516..89d54518 100644 --- a/testing/test_se2.py +++ b/testing/test_se2.py @@ -2,7 +2,7 @@ # # Mk4 SE2 (second secure element) test cases and fixtures. # -# - use 'simulator.py --eff' for these +# - use 'simulator.py' without '--eff' for these # import pytest, struct, time from collections import namedtuple @@ -497,7 +497,6 @@ def test_ux_duress_choices(with_wipe, subchoice, expect, xflags, xargs, need_keypress('x') time.sleep(.1) - pick_menu_item('Activate Wallet') time.sleep(.1) _, story = cap_story() @@ -512,9 +511,7 @@ def test_ux_duress_choices(with_wipe, subchoice, expect, xflags, xargs, assert xp == wallet.hwif(as_private=False) assert not get_setting('multisig') # multisig is not copied - assert not get_setting('tp') # trick pins are not copied - assert not get_setting('bkpw') # backup password is not copied - assert not get_setting('usr') # hsm users are not copied + # re-login to recover normal seed reset_seed_words() repl.exec('pa.tmp_value=False; pa.setup(pa.pin); pa.login()') diff --git a/testing/test_sign.py b/testing/test_sign.py index 43dd443d..533cadcf 100644 --- a/testing/test_sign.py +++ b/testing/test_sign.py @@ -2345,7 +2345,7 @@ def test_batch_sign(num_tx, ui_path, action, fake_txn, need_keypress, pytest.skip("classic sign") _, story = cap_story() - assert "Press (9) to use Batch Sign" in story + assert "Press (9) to select all files for potential signing" in story need_keypress("9") time.sleep(.1) diff --git a/testing/test_ux.py b/testing/test_ux.py index aafc5d5c..f4e86b2d 100644 --- a/testing/test_ux.py +++ b/testing/test_ux.py @@ -2,7 +2,9 @@ # import pytest, time, os, re, hashlib from helpers import xfp2str, prandom -from constants import AF_CLASSIC +from constants import AF_CLASSIC, simulator_fixed_words +from mnemonic import Mnemonic +from pycoin.key.BIP32Node import BIP32Node def test_get_secrets(get_secrets, master_xpub): @@ -127,17 +129,43 @@ def pass_word_quiz(need_keypress, cap_story): @pytest.mark.qrcode @pytest.mark.parametrize('multisig', [False, 'multisig']) +@pytest.mark.parametrize('st', ["b39pass", "eph", None]) @pytest.mark.parametrize('reuse_pw', [False, True]) @pytest.mark.parametrize('save_pw', [False, True]) -def test_make_backup(multisig, goto_home, pick_menu_item, cap_story, need_keypress, open_microsd, microsd_path, unit_test, cap_menu, word_menu_entry, pass_word_quiz, reset_seed_words, import_ms_wallet, get_setting, cap_screen_qr, reuse_pw, save_pw, settings_set, settings_remove): +def test_make_backup(multisig, goto_home, pick_menu_item, cap_story, need_keypress, st, + open_microsd, microsd_path, unit_test, cap_menu, word_menu_entry, + pass_word_quiz, reset_seed_words, import_ms_wallet, get_setting, + cap_screen_qr, reuse_pw, save_pw, settings_set, settings_remove, + generate_ephemeral_words, set_bip39_pw, verify_backup_file, + check_and_decrypt_backup, restore_backup_cs): # Make an encrypted 7z backup, verify it, and even restore it! - if multisig: + # need to make multisig in my main wallet + if multisig and st != "eph": import_ms_wallet(15, 15) need_keypress('y') time.sleep(.1) assert len(get_setting('multisig')) == 1 + if st == "b39pass": + xfp_pass = set_bip39_pw("coinkite", reset=False) + _, story = cap_story() + assert "Above is the master key fingerprint of the current wallet" in story + need_keypress("y") + assert not get_setting('multisig', None) + elif st == "eph": + eph_seed = generate_ephemeral_words(num_words=24, dice=False, from_main=True) + _, story = cap_story() + assert "New ephemeral master key in effect" in story + need_keypress("y") + + if multisig: + # make multisig in ephemeral wallet + import_ms_wallet(15, 15, dev_key=True, common="605'/0'/0'") + need_keypress('y') + time.sleep(.1) + assert len(get_setting('multisig')) == 1 + if reuse_pw: settings_set('bkpw', ' '.join('zoo' for _ in range(12))) else: @@ -149,6 +177,18 @@ def test_make_backup(multisig, goto_home, pick_menu_item, cap_story, need_keypre pick_menu_item('Backup System') title, body = cap_story() + if st: + if st == "b39pass": + assert "BIP39 passphrase is in effect" in body + assert "ignores passphrases and produces backup of main seed" in body + assert "(2) to back-up BIP39 passphrase wallet" in body + if st == "eph": + assert "An ephemeral seed is in effect" in body + assert "so backup will be of that seed" in body + + need_keypress("y") + time.sleep(.1) + title, body = cap_story() if reuse_pw: assert ' 1: zoo' in body @@ -181,7 +221,7 @@ def test_make_backup(multisig, goto_home, pick_menu_item, cap_story, need_keypre assert "Press (1) to save" in body if save_pw: need_keypress('1') - time.sleep(.01) + time.sleep(.1) assert get_setting('bkpw') == ' '.join(words) else: @@ -193,6 +233,12 @@ def test_make_backup(multisig, goto_home, pick_menu_item, cap_story, need_keypre title, body = cap_story() time.sleep(0.1) + if st == "b39pass" and multisig: + # correct settings switch back? + # multisig is only in main wallet + # must not be copied from main to b39pass + # must not be available after backup done + assert not get_setting('multisig', None) files = [] for copy in range(2): @@ -218,68 +264,17 @@ def test_make_backup(multisig, goto_home, pick_menu_item, cap_story, need_keypre need_keypress('x') time.sleep(.01) - # Check on-device verify UX works. - goto_home() - pick_menu_item('Advanced/Tools') - pick_menu_item('Backup') - pick_menu_item('Verify Backup') - time.sleep(0.1) - title, body = cap_story() - assert "Select file" in body - need_keypress('y') - time.sleep(0.1) - pick_menu_item(os.path.basename(fn)) - - time.sleep(0.1) - title, body = cap_story() - assert "Backup file CRC checks out okay" in body - - - # List contents using unix tools - from subprocess import check_output - import re - pn = microsd_path(files[0]) - out = check_output(['7z', 'l', pn], encoding='utf8') - xfname, = re.findall('[a-z0-9]{4,30}.txt', out) - print(f"Filename inside 7z: {xfname}") - assert xfname in out - assert 'Method = 7zAES' in out - - # does decryption; at least for CRC purposes - out = check_output(['7z', 't', '-p'+' '.join(words), pn, xfname], encoding='utf8') - assert "Everything is Ok" in out, out + verify_backup_file(fn) + check_and_decrypt_backup(fn, words) for i in range(10): need_keypress('x') time.sleep(.01) # test verify on device (CRC check) - - # try decrypt on microptyhon - unit_test('devtest/clear_seed.py') + avail_settings = ['multisig'] if multisig else None + restore_backup_cs(files[0], words, avail_settings=avail_settings) - m = cap_menu() - assert m[0] == 'New Seed Words' - pick_menu_item('Import Existing') - pick_menu_item('Restore Backup') - - # skip - title, body = cap_story() - assert 'files to pick from' in body - need_keypress('y'); time.sleep(.01) - - pick_menu_item(files[0]) - - word_menu_entry(words) - title, body = cap_story() - assert title == 'Success!' - assert 'has been successfully restored' in body - - if multisig: - assert len(get_setting('multisig')) == 1 - - # avoid simulator reboot; restore normal state - unit_test('devtest/abort_ux.py') reset_seed_words() settings_remove('multisig') @@ -489,7 +484,6 @@ def test_import_prv(way, testnet, pick_menu_item, cap_story, need_keypress, unit unit_test('devtest/clear_seed.py') - from pycoin.key.BIP32Node import BIP32Node node = BIP32Node.from_master_secret(os.urandom(32), netcode=netcode) prv = node.hwif(as_private=True)+'\n' if testnet: @@ -756,15 +750,15 @@ def test_bip39_complex(target, goto_home, pick_menu_item, cap_story, @pytest.mark.qrcode @pytest.mark.parametrize('mode', ['words', 'xprv', 'ms']) @pytest.mark.parametrize('b39_word', ['', 'AbcZz1203']) -def test_show_seed(mode, b39_word, goto_home, pick_menu_item, cap_story, need_keypress, sim_exec, - cap_menu, get_pp_sofar, get_secrets, cap_screen_qr, set_encoded_secret, qr_quality_check): +def test_show_seed(mode, b39_word, goto_home, pick_menu_item, cap_story, need_keypress, + sim_exec, cap_menu, get_pp_sofar, get_secrets, cap_screen_qr, + set_encoded_secret, qr_quality_check, reset_seed_words, set_bip39_pw): + reset_seed_words() if mode == 'words': - # Check the seed words are displayed correctly: the new "View Seed Words" feature - sim_exec("import stash; stash.bip39_passphrase = '%s'" % b39_word) + set_bip39_pw(b39_word, reset=False) + words = simulator_fixed_words.split(" ") - v = get_secrets() - words = v['mnemonic'].split(' ') else: if b39_word: return @@ -785,11 +779,11 @@ def test_show_seed(mode, b39_word, goto_home, pick_menu_item, cap_story, need_ke pick_menu_item('Danger Zone') pick_menu_item('Seed Functions') pick_menu_item('View Seed Words') - time.sleep(.01); + time.sleep(.01) title, body = cap_story() assert 'Are you SURE' in body assert 'can control all funds' in body - need_keypress('y'); # skip warning + need_keypress('y') # skip warning time.sleep(0.01) title, body = cap_story() @@ -803,9 +797,15 @@ def test_show_seed(mode, b39_word, goto_home, pick_menu_item, cap_story, need_ke if b39_word: assert lines[26] == 'BIP-39 Passphrase:' - assert b39_word in lines[27] - - sim_exec("import stash; stash.bip39_passphrase = ''") + assert "*" in lines[27] + assert "Seed+Passphrase" in lines[29] + ek = lines[30] + seed = Mnemonic.to_seed(simulator_fixed_words, passphrase=b39_word) + expect = BIP32Node.from_master_secret(seed, netcode="XTN") + esk = expect.hwif(as_private=True) + assert esk == ek + else: + assert "BIP-39 Passphrase" not in body qr_expect = ' '.join(w[0:4].upper() for w in words) @@ -983,7 +983,7 @@ def test_bip39_pw_signing_xfp_ux(goto_home, pick_menu_item, need_keypress, cap_s time.sleep(0.3) title, _ = cap_story() assert title == "[0C9DC99D]" - need_keypress("y") # confirm new wallet + need_keypress("y") # confirm passphrase pick_menu_item("Ready To Sign") time.sleep(0.1) title_sign, _ = cap_story()