From a1f6743de250a80dbad702dd0b4c0f06695c36b2 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Wed, 6 Dec 2023 09:02:36 +0100 Subject: [PATCH 01/52] Export SeedQR --- releases/ChangeLog.md | 1 + shared/actions.py | 36 ++++++++++++++++++++++++++++++++++++ shared/flow.py | 3 ++- shared/qrs.py | 4 ++-- shared/ux.py | 4 ++-- testing/test_ux.py | 40 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 83 insertions(+), 5 deletions(-) diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index a279ca9b..a3a16785 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -1,6 +1,7 @@ ## 5.2.1 - 2023-11-XX - New Feature: Temporary Seed from COLDCARD encrypted backup. +- New Feature: Export seed as SeedQR - Enhancement: Add current temporary seed to Seed Vault from within Seed Vault menu. If current seed is temporary and not saved yet, `Add current tmp` menu item is shown in Seed Vault menu. diff --git a/shared/actions.py b/shared/actions.py index 4503b616..083ee70b 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -719,6 +719,42 @@ async def view_seed_words(*a): stash.blank_object(msg) stash.blank_object(raw) +async def export_seedqr(*a): + # see standard: + import bip39, stash + + if not await ux_confirm('The next screen will show the seed words in a QR code.' + '\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) + + # Note: cannot reach this menu item if no words. If they are tmp, that's cool. + + 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() + + if sv.mode != 'words': + raise ValueError(sv.mode) + + words = bip39.b2a_words(sv.raw).split(' ') + + dis.busy_bar(False) + qr = ''.join('%04d'% bip39.get_word_index(w) for w in words) + + del words + + from ux import show_qr_code + await show_qr_code(qr, True) + + stash.blank_object(qr) + async def damage_myself(): # called when it's time to disable ourselves due to various # features related to duress and so on diff --git a/shared/flow.py b/shared/flow.py index 34c644f8..8364d711 100644 --- a/shared/flow.py +++ b/shared/flow.py @@ -208,8 +208,9 @@ 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_ephemeral_to_master), + MenuItem('Export SeedQR', f=export_seedqr, + predicate=lambda: settings.get('words', True)), ] DangerZoneMenu = [ diff --git a/shared/qrs.py b/shared/qrs.py index 7f3b77dd..d8dcaeff 100644 --- a/shared/qrs.py +++ b/shared/qrs.py @@ -29,14 +29,14 @@ class QRDisplaySingle(UserInteraction): # - inverted QR (black/white swap) still readable by scanners, altho wrong if self.is_alnum: # targeting 'alpha numeric' mode, nice and dense; caps only tho - enc = uqr.Mode_ALPHANUMERIC + enc = uqr.Mode_ALPHANUMERIC if not msg.isdigit() else uqr.Mode_NUMERIC msg = msg.upper() else: # has to be 'binary' mode, altho shorter msg, typical 34-36 enc = uqr.Mode_BYTE # can fail if not enough space in QR - self.qr_data = uqr.make(msg, min_version=3, max_version=11, encoding=enc) + self.qr_data = uqr.make(msg, min_version=2, max_version=11, encoding=enc) def redraw(self): # Redraw screen. diff --git a/shared/ux.py b/shared/ux.py index c904a4b0..70518038 100644 --- a/shared/ux.py +++ b/shared/ux.py @@ -364,9 +364,9 @@ async def show_qr_codes(addrs, is_alnum, start_n): o = QRDisplaySingle(addrs, is_alnum, start_n, sidebar=None) await o.interact_bare() -async def show_qr_code(data, is_alnum): +async def show_qr_code(data, is_alnum, msg=None): from qrs import QRDisplaySingle - o = QRDisplaySingle([data], is_alnum) + o = QRDisplaySingle([data], is_alnum, sidebar=msg) await o.interact_bare() async def ux_enter_bip32_index(prompt, can_cancel=False, unlimited=False): diff --git a/testing/test_ux.py b/testing/test_ux.py index 1a077fd9..10d32036 100644 --- a/testing/test_ux.py +++ b/testing/test_ux.py @@ -659,6 +659,46 @@ def test_show_seed(mode, b39_word, goto_home, pick_menu_item, cap_story, need_ke need_keypress('y') # clear screen +@pytest.mark.qrcode +@pytest.mark.parametrize("data", [ + (simulator_fixed_words, [2007, 1585, 123, 131, 745, 43, 1506, 1930, 664, 749, 1200, 113, 1321, 330, 1764, 698, 1160, 656, 647, 1424, 135, 767, 987, 335]), + ("task tube actor end cannon potato sign card occur donkey soup baby tooth bless barely pull gap priority", [1776, 1872, 21, 588, 267, 1350, 1602, 276, 1222, 521, 1663, 136, 1830, 189, 148, 1386, 762, 1367]), + ("vacuum bridge buddy supreme exclude milk consider tail expand wasp pattern nuclear", [1924,222,235,1743,631,1124,378,1770,641,1980,1290,1210]), + ("approve fruit lens brass ring actual stool coin doll boss strong rate", "008607501025021714880023171503630517020917211425"), + ("good battle boil exact add seed angle hurry success glad carbon whisper", "080301540200062600251559007008931730078802752004"), + ("forum undo fragile fade shy sign arrest garment culture tube off merit", "073318950739065415961602009907670428187212261116"), + ("sound federal bonus bleak light raise false engage round stock update render quote truck quality fringe palace foot recipe labor glow tortoise potato still", "166206750203018810361417065805941507171219081456140818651401074412730727143709940798183613501710"), + ("atom solve joy ugly ankle message setup typical bean era cactus various odor refuse element afraid meadow quick medal plate wisdom swap noble shallow", "011416550964188800731119157218870156061002561932122514430573003611011405110613292018175411971576"), + ("attack pizza motion avocado network gather crop fresh patrol unusual wild holiday candy pony ranch winter theme error hybrid van cereal salon goddess expire", "011513251154012711900771041507421289190620080870026613431420201617920614089619290300152408010643"), +]) +def test_show_seed_qr(data, 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, set_seed_words): + n = 4 # SeedQr 4 str chars for each index + words, qr_expect = data + if isinstance(qr_expect, str): + qr_expect = [int(qr_expect[i:i+n]) for i in range(0, len(qr_expect), n)] + set_seed_words(words) + + goto_home() + pick_menu_item('Advanced/Tools') + pick_menu_item('Danger Zone') + pick_menu_item('Seed Functions') + pick_menu_item('Export SeedQR') + + 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 + time.sleep(0.01) + + qr = cap_screen_qr().decode('ascii') + qr = [int(qr[i:i+n]) for i in range(0, len(qr), n)] + assert qr == qr_expect + + need_keypress('y') # clear screen + def test_destroy_seed(goto_home, pick_menu_item, cap_story, need_keypress, sim_exec, cap_menu, get_secrets): From 285c90999eb37354ebac4fff4f6fba6706a2f762 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Thu, 7 Dec 2023 12:52:49 +0100 Subject: [PATCH 02/52] bugfix: add missing ftux for extended key import (as master) --- releases/ChangeLog.md | 1 + shared/seed.py | 2 +- testing/test_ux.py | 8 +++++--- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index a3a16785..13e1a98f 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -10,6 +10,7 @@ - Enhancement: `12 Words` menu option preferred on the top of the menu in all the seed menus (rather than 24 words). - Bugfix: Handle any failures in slot reading when loading settings +- Bugfix: Add missing First Time UX for extended key import as master seed ## 5.2.0 - 2023-10-10 diff --git a/shared/seed.py b/shared/seed.py index c3cf971f..c569b8f3 100644 --- a/shared/seed.py +++ b/shared/seed.py @@ -543,7 +543,7 @@ async def ephemeral_seed_generate(nwords): async def set_seed_extended_key(extended_key): encoded, chain = xprv_to_encoded_secret(extended_key) set_seed_value(encoded=encoded, chain=chain) - goto_top_menu() + goto_top_menu(first_time=True) async def set_ephemeral_seed_extended_key(extended_key, meta=None): encoded, chain = xprv_to_encoded_secret(extended_key) diff --git a/testing/test_ux.py b/testing/test_ux.py index 10d32036..89bc2177 100644 --- a/testing/test_ux.py +++ b/testing/test_ux.py @@ -277,7 +277,9 @@ def test_import_from_dice(count, nwords, goto_home, pick_menu_item, cap_story, n @pytest.mark.parametrize('multiple_runs', range(3)) @pytest.mark.parametrize('nwords', [12, 24]) -def test_new_wallet(nwords, goto_home, pick_menu_item, cap_story, need_keypress, cap_menu, get_secrets, unit_test, pass_word_quiz, multiple_runs, reset_seed_words, expect_ftux): +def test_new_wallet(nwords, goto_home, pick_menu_item, cap_story, need_keypress, + cap_menu, get_secrets, unit_test, pass_word_quiz, multiple_runs, + reset_seed_words, expect_ftux): # generate a random wallet, and check seeds are what's shown to user, etc unit_test('devtest/clear_seed.py') @@ -313,7 +315,7 @@ def test_new_wallet(nwords, goto_home, pick_menu_item, cap_story, need_keypress, @pytest.mark.parametrize('testnet', [True, False]) def test_import_prv(way, testnet, pick_menu_item, cap_story, need_keypress, unit_test, cap_menu, word_menu_entry, get_secrets, microsd_path, multiple_runs, reset_seed_words, - nfc_write_text, settings_set, virtdisk_path): + nfc_write_text, settings_set, virtdisk_path, expect_ftux): if testnet: netcode = "XTN" settings_set('chain', 'XTN') @@ -370,7 +372,7 @@ def test_import_prv(way, testnet, pick_menu_item, cap_story, need_keypress, unit need_keypress("y") pick_menu_item(fname) - unit_test('devtest/abort_ux.py') + expect_ftux() v = get_secrets() From f8ac8eda8967e83f6a57ef97709f082135e54942 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Thu, 7 Dec 2023 16:21:59 +0100 Subject: [PATCH 03/52] Upgrade Firmware menu item is hidden if temporary seed is active --- releases/ChangeLog.md | 3 ++- shared/flow.py | 10 +++++++--- testing/test_ephemeral.py | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index 13e1a98f..157b815b 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -10,7 +10,8 @@ - Enhancement: `12 Words` menu option preferred on the top of the menu in all the seed menus (rather than 24 words). - Bugfix: Handle any failures in slot reading when loading settings -- Bugfix: Add missing First Time UX for extended key import as master seed +- Bugfix: Add missing First Time UX for extended key import as master seed +- Bugfix: Hide `Upgrade Firmware` menu item if temporary seed is active ## 5.2.0 - 2023-10-10 diff --git a/shared/flow.py b/shared/flow.py index 8364d711..e8cb61e6 100644 --- a/shared/flow.py +++ b/shared/flow.py @@ -43,6 +43,10 @@ def nfc_enabled(): def vdisk_enabled(): return bool(settings.get('vidsk', 0)) +def is_not_tmp(): + from pincodes import pa + return not bool(pa.tmp_value) + def se2_and_real_secret(): from pincodes import pa return (not pa.is_secret_blank()) and (not pa.tmp_value) @@ -171,7 +175,7 @@ AdvancedVirginMenu = [ # No PIN, no secrets yet (factory fresh) # xxxxxxxxxxxxxxxx MenuItem("View Identity", f=view_ident), MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu), - MenuItem('Upgrade Firmware', menu=UpgradeMenu), + MenuItem('Upgrade Firmware', menu=UpgradeMenu, predicate=is_not_tmp), MenuItem('Paper Wallets', f=make_paper_wallet, predicate=lambda: make_paper_wallet), MenuItem('Perform Selftest', f=start_selftest), MenuItem('Secure Logout', f=logout_now), @@ -181,7 +185,7 @@ AdvancedPinnedVirginMenu = [ # Has PIN but no secrets yet # xxxxxxxxxxxxxxxx MenuItem("View Identity", f=view_ident), MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu), - MenuItem("Upgrade Firmware", menu=UpgradeMenu), + MenuItem("Upgrade Firmware", menu=UpgradeMenu, predicate=is_not_tmp), MenuItem("File Management", menu=FileMgmtMenu), MenuItem('Paper Wallets', f=make_paper_wallet, predicate=lambda: make_paper_wallet), MenuItem('Perform Selftest', f=start_selftest), @@ -265,7 +269,7 @@ AdvancedNormalMenu = [ # xxxxxxxxxxxxxxxx MenuItem("Backup", menu=BackupStuffMenu), MenuItem('Export Wallet', predicate=has_secrets, menu=WalletExportMenu), # also inside FileMgmt - MenuItem("Upgrade Firmware", menu=UpgradeMenu), + MenuItem("Upgrade Firmware", menu=UpgradeMenu, predicate=is_not_tmp), MenuItem("File Management", menu=FileMgmtMenu), MenuItem('Derive Seed B85', f=drv_entro_start), MenuItem("View Identity", f=view_ident), diff --git a/testing/test_ephemeral.py b/testing/test_ephemeral.py index fdf67113..119f3809 100644 --- a/testing/test_ephemeral.py +++ b/testing/test_ephemeral.py @@ -1280,4 +1280,41 @@ def test_temporary_from_backup(multisig, backup_system, import_ms_wallet, get_se else: restore_main_seed(False) + +def test_tmp_upgrade_disabled(reset_seed_words, need_keypress, pick_menu_item, + cap_story, cap_menu, goto_home, unit_test, + import_ephemeral_xprv): + reset_seed_words() + goto_home() + pick_menu_item("Advanced/Tools") + time.sleep(.1) + m = cap_menu() + assert "Upgrade Firmware" in m + node = BIP32Node.from_master_secret(os.urandom(32), netcode="XTN") + xfp = node.fingerprint().hex().upper() + k0 = node.hwif(as_private=True) + import_ephemeral_xprv("sd", extended_key=k0, seed_vault=True, from_main=True) + goto_home() + pick_menu_item("Advanced/Tools") + time.sleep(.1) + m = cap_menu() + assert "Upgrade Firmware" not in m + + # Virgin CC + unit_test('devtest/clear_seed.py') + + m = cap_menu() + assert m[0] == 'New Seed Words' + goto_home() + pick_menu_item("Advanced/Tools") + time.sleep(.1) + m = cap_menu() + assert "Upgrade Firmware" in m + import_ephemeral_xprv("sd", extended_key=k0, seed_vault=True, from_main=True) + goto_home() + pick_menu_item("Advanced/Tools") + time.sleep(.1) + m = cap_menu() + assert "Upgrade Firmware" not in m + # EOF From 9188c7faf25af5c5042e732a78e9d57848336db2 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Thu, 7 Dec 2023 14:16:00 +0100 Subject: [PATCH 04/52] bugfix: do not allow to import master seed as temporary --- releases/ChangeLog.md | 1 + shared/nvstore.py | 25 ++++++----- shared/pincodes.py | 35 ++++++++++++--- shared/seed.py | 11 ++--- testing/test_ephemeral.py | 89 +++++++++++++++++++++++++++++++++++++-- 5 files changed, 136 insertions(+), 25 deletions(-) diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index 157b815b..72a5cc0e 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -12,6 +12,7 @@ - Bugfix: Handle any failures in slot reading when loading settings - Bugfix: Add missing First Time UX for extended key import as master seed - Bugfix: Hide `Upgrade Firmware` menu item if temporary seed is active +- Bugfix: Disallow using master seed as temporary seed ## 5.2.0 - 2023-10-10 diff --git a/shared/nvstore.py b/shared/nvstore.py index 6a5d8d0b..2b18ab94 100644 --- a/shared/nvstore.py +++ b/shared/nvstore.py @@ -115,12 +115,25 @@ class SettingsObject: ctr = ustruct.pack('<4I', 4, 3, 2, pos) return aes256ctr.new(self.nvram_key, ctr) + @staticmethod + def hash_key(secret): + # hash up the secret... without decoding it or similar + assert len(secret) >= 32 + + s = sha256(secret) + + for round in range(5): + s.update('pad') + s = sha256(s.digest()) + + return s.digest() + def set_key(self, new_secret=None): # System settings (not secrets) are stored in flash, encrypted with this # key that is derived from main wallet secret. Call this method when the secret # is first loaded, or changes for some reason. from pincodes import pa - from stash import blank_object, SensitiveValues + from stash import blank_object key = None mine = False @@ -136,15 +149,7 @@ class SettingsObject: if new_secret: # hash up the secret... without decoding it or similar - assert len(new_secret) >= 32 - - s = sha256(new_secret) - - for round in range(5): - s.update('pad') - s = sha256(s.digest()) - - key = s.digest() + key = self.hash_key(new_secret) if mine: blank_object(new_secret) diff --git a/shared/pincodes.py b/shared/pincodes.py index 9d450d65..b606b61e 100644 --- a/shared/pincodes.py +++ b/shared/pincodes.py @@ -412,7 +412,8 @@ class PinAttempt: self.roundtrip(7, fw_upgrade=(start, length)) # not-reached - def new_main_secret(self, raw_secret=None, chain=None, bip39pw='', blank=False): + def new_main_secret(self, raw_secret=None, chain=None, bip39pw='', blank=False, + target_nvram_key=None): # Main secret has changed: reset the settings+their key, # and capture xfp/xpub # if None is provided as raw_secret -> restore to main seed @@ -438,7 +439,13 @@ class PinAttempt: settings.blank() old_values = None else: - settings.set_key(raw_secret) + if target_nvram_key is None: + settings.set_key(raw_secret) + else: + # we already have hashed nvram key calculated + # from self.tmp_secret - use it + settings.nvram_key = target_nvram_key + settings.load() # Recalculate xfp/xpub values (depends both on secret and chain) @@ -462,17 +469,30 @@ class PinAttempt: settings.load() self.state_flags |= PA_ZERO_SECRET - def tmp_secret(self, encoded, chain=None, bip39pw=''): # Use indicated secret and stop using the SE; operate like this until reboot + from glob import settings + val = bytes(encoded + bytes(AE_SECRET_LEN - len(encoded))) if self.tmp_value == val: # noop - already enabled - return False + return False, "Temporary master key already in use." + + target_nvram_key = None + if encoded is not None: + # disallow using master seed as temporary + master_err = "Cannot use master seed as temporary." + target_nvram_key = settings.hash_key(encoded) + if settings.master_nvram_key: + assert self.tmp_value + if target_nvram_key == settings.master_nvram_key: + return False, master_err + else: + if target_nvram_key == settings.nvram_key: + return False, master_err if not self.tmp_value: # leaving from master seed, might capture some useful values - from glob import settings settings.leaving_master_seed() self.tmp_value = val @@ -482,9 +502,10 @@ class PinAttempt: # 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, bip39pw=bip39pw) + self.new_main_secret(self.tmp_value, chain=chain, bip39pw=bip39pw, + target_nvram_key=target_nvram_key) - return True + return True, None def trick_request(self, method_num, data): # send/recv a trick-pin related request (mk4 only) diff --git a/shared/seed.py b/shared/seed.py index c569b8f3..ff8c1ba4 100644 --- a/shared/seed.py +++ b/shared/seed.py @@ -471,15 +471,16 @@ async def set_ephemeral_seed(encoded, chain=None, summarize_ux=True, bip39pw='', await add_seed_to_vault(encoded, meta=meta) dis.fullscreen("Wait...") - applied = pa.tmp_secret(encoded, chain=chain, bip39pw=bip39pw) + applied, err_msg = pa.tmp_secret(encoded, chain=chain, bip39pw=bip39pw) + dis.progress_bar_show(1) - xfp = settings.get("xfp", None) - if xfp: - xfp = "[" + xfp2str(xfp) + "]" + if not applied: - await ux_show_story(title=xfp, msg="Temporary master key already in use.") + await ux_show_story(title="FAILED", msg=err_msg) return + + xfp = "[" + xfp2str(settings.get("xfp", 0)) + "]" if summarize_ux: await ux_show_story(title=xfp, msg="New temporary master key is in effect now.") diff --git a/testing/test_ephemeral.py b/testing/test_ephemeral.py index 119f3809..2e69a2b3 100644 --- a/testing/test_ephemeral.py +++ b/testing/test_ephemeral.py @@ -4,7 +4,8 @@ # import pytest, time, re, os, shutil, pdb, hashlib -from constants import simulator_fixed_tpub, simulator_fixed_words, simulator_fixed_xfp, simulator_fixed_xpub +from constants import simulator_fixed_tpub, simulator_fixed_xfp, simulator_fixed_xpub +from constants import simulator_fixed_words, simulator_fixed_tprv from ckcc.protocol import CCProtocolPacker from txn import fake_txn from test_ux import word_menu_entry @@ -763,8 +764,8 @@ def test_activate_current_tmp_secret(reset_seed_words, goto_eph_seed_menu, title, story = cap_story() assert "Temporary master key already in use" in story - already_used_xfp = title[1:-1] - assert already_used_xfp == in_effect_xfp == expected_xfp + assert title == "FAILED" + assert in_effect_xfp == expected_xfp need_keypress("y") @@ -1317,4 +1318,86 @@ def test_tmp_upgrade_disabled(reset_seed_words, need_keypress, pick_menu_item, m = cap_menu() assert "Upgrade Firmware" not in m + +def test_import_master_as_tmp(reset_seed_words, goto_eph_seed_menu, cap_story, + ephemeral_seed_disabled, pick_menu_item, goto_home, + need_keypress, word_menu_entry, settings_set, + confirm_tmp_seed, cap_menu, microsd_path, + restore_main_seed, get_identity_story): + reset_seed_words() + + goto_eph_seed_menu() + ephemeral_seed_disabled() + + # try import same seed as current simulator master + words, expected_xfp = simulator_fixed_words, simulator_fixed_xfp + xfp_str = xfp2str(expected_xfp) + pick_menu_item("Import Words") + pick_menu_item(f"24 Words") + time.sleep(0.1) + + word_menu_entry(words.split()) + time.sleep(.1) + title, story = cap_story() + assert "FAILED" == title + assert 'Cannot use master seed as temporary.' in story + need_keypress("x") + + # go to ephemeral seed and then try to create new ephemeral seed from master + # when in different temporary seed whatsoever + goto_eph_seed_menu() + + # random temporary seed + pick_menu_item("Generate Words") + pick_menu_item(f"12 Words") + need_keypress("6") # skip quiz + need_keypress("y") # yes - I'm sure + confirm_tmp_seed(seedvault=False) + + goto_home() + time.sleep(0.1) + menu = cap_menu() + # ephemeral seed chosen + assert "[" in menu[0] + goto_eph_seed_menu() + pick_menu_item("Import Words") + pick_menu_item(f"24 Words") + time.sleep(0.1) + + word_menu_entry(words.split()) + time.sleep(.1) + title, story = cap_story() + assert "FAILED" == title + assert 'Cannot use master seed as temporary.' in story + need_keypress("x") + + # now import same seed but represented as master extended key + # this works and does not delete master settings as encoded + # secret is different and therefore nvram_key too + fname = "ek_sim.txt" + with open(microsd_path(fname), "w") as f: + f.write(simulator_fixed_tprv) + + goto_eph_seed_menu() + pick_menu_item("Import XPRV") + title, story = cap_story() + if "Press (1)" in story: + need_keypress("1") + + need_keypress("y") # Select file containing... + pick_menu_item(fname) + confirm_tmp_seed(seedvault=False) # allowed + + # verify we are in temporary seed + goto_home() + time.sleep(0.1) + menu = cap_menu() + # ephemeral seed chosen + assert "[" in menu[0] + assert xfp_str in menu[0] + restore_main_seed(preserve_settings=False, seed_vault=False) + story = get_identity_story() + assert "00000000" not in story + assert xfp_str in story + # EOF From 8ec2c7f88ce584acbd9fa280913d0655d9fd0b3a Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Wed, 22 Nov 2023 12:20:16 +0100 Subject: [PATCH 05/52] IOError is deprecated, use OSError --- shared/actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/actions.py b/shared/actions.py index 083ee70b..3bc9172c 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -1672,7 +1672,7 @@ async def file_picker(msg, suffix=None, min_size=1, max_size=1000000, taster=Non if taster is not None: try: yummy = taster(full_fname) - except IOError: + except OSError: #print("fail: %s" % full_fname) yummy = False From 84215b172119b6ddbcbaa77923c6b601ffa22f90 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Wed, 13 Dec 2023 15:25:47 +0100 Subject: [PATCH 06/52] one instant retry on AE_FAIL --- releases/ChangeLog.md | 1 + shared/pincodes.py | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index 72a5cc0e..d9ce84c5 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -9,6 +9,7 @@ deferring card read (and decryption) until after `Restore Saved` menu item is selected. - Enhancement: `12 Words` menu option preferred on the top of the menu in all the seed menus (rather than 24 words). +- Enhancement: One instant retry on SE1 comm failures - Bugfix: Handle any failures in slot reading when loading settings - Bugfix: Add missing First Time UX for extended key import as master seed - Bugfix: Hide `Upgrade Firmware` menu item if temporary seed is active diff --git a/shared/pincodes.py b/shared/pincodes.py index b606b61e..4ebad4ef 100644 --- a/shared/pincodes.py +++ b/shared/pincodes.py @@ -100,6 +100,12 @@ PIN_ATTEMPT_SIZE = const(248+32) # small cache of pin-prefix to words, for 608a based systems _word_cache = [] +def retry_ae_fail(*args): + err = ckcc.gate(*args) + if err == -106: # AE_FAIL + err = ckcc.gate(*args) + return err + class BootloaderError(RuntimeError): pass @@ -252,7 +258,7 @@ class PinAttempt: #print("> tx: %s" % b2a_hex(buf)) - err = ckcc.gate(18, buf, method_num) + err = retry_ae_fail(18, buf, method_num) #print("[%d] rx: %s" % (err, b2a_hex(buf))) From 4a39fc82f1686089854976d3537562c5e3e59c78 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Sat, 25 Nov 2023 17:11:24 +0100 Subject: [PATCH 07/52] allow passphrase via USB if passphrase already set (work on master seed in that case); show password over USB UX change --- releases/ChangeLog.md | 1 + shared/auth.py | 32 +++++++++----------- shared/drv_entro.py | 2 +- shared/usb.py | 3 +- testing/test_bip39pw.py | 65 ++++++++++++++++++++++++++++++++++++----- 5 files changed, 74 insertions(+), 29 deletions(-) diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index d9ce84c5..157613f4 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -10,6 +10,7 @@ - Enhancement: `12 Words` menu option preferred on the top of the menu in all the seed menus (rather than 24 words). - Enhancement: One instant retry on SE1 comm failures +- Enhancement: Allow passphrase via USB if passphrase already set - operates on master seed. - Bugfix: Handle any failures in slot reading when loading settings - Bugfix: Add missing First Time UX for extended key import as master seed - Bugfix: Hide `Upgrade Firmware` menu item if temporary seed is active diff --git a/shared/auth.py b/shared/auth.py index baeeaa4d..fac808f7 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -1196,29 +1196,24 @@ class NewPassphrase(UserAuthorizedAction): title = "Passphrase" bypass_tmp = True escape = "2" - showit = False while 1: - if showit: - ch = await ux_show_story('Given:\n\n%s\n\nShould we switch to that wallet now?' - '\n\nOK to continue, X to cancel.' % self._pw, title=title) - else: - msg = ('BIP-39 passphrase (%d chars long) has been provided over ' - 'USB connection. Should we switch to that wallet now?\n\n') - if pa.tmp_value and settings.get("words", True): - escape += "1" - msg += "Press (1) to add passphrase to currently active temporary seed. " - - msg += ('Press (2) to view the provided passphrase.\n\n' - 'OK to continue, X to cancel.') - ch = await ux_show_story(msg=msg % len(self._pw), title=title, escape=escape) + msg = ('BIP-39 passphrase (%d chars long) has been provided over ' + 'USB connection. Should we switch to that wallet now?\n\n') + if pa.tmp_value and settings.get("words", True): + escape += "1" + msg += "Press (1) to add passphrase to currently active temporary seed. " + msg += ('Press (2) to view the provided passphrase.\n\n' + 'OK to continue, X to cancel.') + ch = await ux_show_story(msg=msg % len(self._pw), title=title, escape=escape) if ch == '2': - showit = True + await ux_show_story('Provided:\n\n%s\n\n' % self._pw, title=title) continue - elif ch == '1': - bypass_tmp = False + else: + if ch == '1': + bypass_tmp = False - break + break try: if ch not in 'y1': @@ -1234,7 +1229,6 @@ class NewPassphrase(UserAuthorizedAction): self.result = settings.get('xpub') - except BaseException as exc: self.failed = "Exception" sys.print_exception(exc) diff --git a/shared/drv_entro.py b/shared/drv_entro.py index 4d453427..190c8acc 100644 --- a/shared/drv_entro.py +++ b/shared/drv_entro.py @@ -257,7 +257,7 @@ async def drv_entro_step2(_1, picked, _2): dis.fullscreen("Applying...") from actions import goto_top_menu from glob import settings - xfp_str = xfp2str(settings.get("xfp")) + xfp_str = xfp2str(settings.get("xfp", 0)) await seed.set_ephemeral_seed( encoded, meta='BIP85 Derived from [%s], index=%d' % (xfp_str, index) diff --git a/shared/usb.py b/shared/usb.py index 862dec65..0a4d5d30 100644 --- a/shared/usb.py +++ b/shared/usb.py @@ -546,10 +546,11 @@ class USBHandler: if cmd == 'pass': # bip39 passphrase provided, maybe use it if authorized assert self.encrypted_req, 'must encrypt' + import stash from auth import start_bip39_passphrase from glob import settings - assert settings.get('words', True), 'no seed' + assert settings.get('words', True) or stash.bip39_passphrase, 'no seed' assert len(args) < 400, 'too long' pw = str(args, 'utf8') assert len(pw) < 100, 'too long' diff --git a/testing/test_bip39pw.py b/testing/test_bip39pw.py index 4d717a49..b0d268f0 100644 --- a/testing/test_bip39pw.py +++ b/testing/test_bip39pw.py @@ -54,17 +54,19 @@ def test_b9p_basic(pw, set_bip39_pw): def set_bip39_pw(dev, need_keypress, reset_seed_words, cap_story, sim_execfile): - def doit(pw, reset=True, seed_vault=False): + def doit(pw, reset=True, seed_vault=False, on_tmp=False): # reset from previous runs if reset: words = reset_seed_words() else: conts = sim_execfile('devtest/get-secrets.py') - assert 'mnemonic' in conts - for l in conts.split("\n"): - if l.startswith("mnemonic ="): - words = l.split("=")[-1].strip().replace('"', '') - break + if 'mnemonic' in conts: + for l in conts.split("\n"): + if l.startswith("mnemonic ="): + words = l.split("=")[-1].strip().replace('"', '') + break + else: + words = simulator_fixed_words # optimization if pw == '': @@ -84,8 +86,15 @@ def set_bip39_pw(dev, need_keypress, reset_seed_words, cap_story, time.sleep(0.050) title, body = cap_story() assert pw in body + need_keypress("y") # go back - need_keypress('y') + time.sleep(.1) + title, body = cap_story() + if on_tmp: + assert "Press (1)" in body + need_keypress("1") + else: + need_keypress("y") time.sleep(.3) title, story = cap_story() @@ -308,6 +317,46 @@ def test_bip39pass_on_ephemeral_seed(generate_ephemeral_words, import_ephemeral_ if on_eph: assert ("BIP-39 Passphrase on [%s]" % parent_fp) in story else: - assert "BIP-39 Passphrase on [0F056943]" in story + assert "BIP-39 Passphrase on [0F056943]" in story + + +@pytest.mark.parametrize("stype", ["words", "xprv", "b39pw"]) +def test_bip39pass_on_ephemeral_seed_usb(generate_ephemeral_words, import_ephemeral_xprv, + need_keypress, pick_menu_item, goto_home, + reset_seed_words, goto_eph_seed_menu, stype, + cap_story, cap_menu, set_bip39_pw, + get_identity_story, settings_set): + settings_set("seedvault", 0) + passphrase = "@coinkite rulez!!" + reset_seed_words() + + goto_eph_seed_menu() + + if stype == "words": + # words + sec = generate_ephemeral_words(24, from_main=True, seed_vault=False) + parent_words = " ".join(sec) + elif stype == "b39pw": + base_pw = "random_pw" + parent_words = simulator_fixed_words + set_bip39_pw(base_pw, reset=False, on_tmp=False) + else: + # node + import_ephemeral_xprv("sd", from_main=True, seed_vault=False) + + goto_home() + if stype == "xprv": + with pytest.raises(Exception) as e: + set_bip39_pw(passphrase, reset=False) + assert "no seed" in e.value.args[0] + return + + parent = Mnemonic.to_seed(parent_words, passphrase=passphrase) + parent_node = BIP32Node.from_master_secret(parent, netcode="XTN") + xpub = parent_node.hwif() + set_bip39_pw(passphrase, reset=False, on_tmp=True if stype == "words" else False) + ident_story = get_identity_story() + assert xpub in ident_story + # EOF From af753c38be9c60ad72eb3b729488ea4a0fd9a561 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Thu, 19 Jan 2023 17:19:53 +0100 Subject: [PATCH 08/52] provide info about Tx level locktimes (nLocktime, nSequence) when signing --- releases/ChangeLog.md | 4 + shared/auth.py | 7 + shared/psbt.py | 136 +++++++++ shared/utils.py | 37 ++- testing/devtest/clear_seed.py | 4 + testing/helpers.py | 18 ++ testing/test_ephemeral.py | 1 - testing/test_seed_xor.py | 40 ++- testing/test_sign.py | 538 +++++++++++++++++++++++++++++++++- 9 files changed, 766 insertions(+), 19 deletions(-) diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index 157613f4..5d2892bf 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -2,6 +2,10 @@ - New Feature: Temporary Seed from COLDCARD encrypted backup. - New Feature: Export seed as SeedQR +- New Feature: Provide user with info about transaction level timelocks + ([nLockTime](https://en.bitcoin.it/wiki/NLockTime), + [nSequence](https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki)) + when signing - Enhancement: Add current temporary seed to Seed Vault from within Seed Vault menu. If current seed is temporary and not saved yet, `Add current tmp` menu item is shown in Seed Vault menu. diff --git a/shared/auth.py b/shared/auth.py index fac808f7..37372a42 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -711,6 +711,13 @@ class ApproveTransaction(UserAuthorizedAction): self.output_change_text(msg) gc.collect() + if self.psbt.ux_notes: + # currently we only have locktimes in ux_notes + msg.write('\nTX LOCKTIMES\n\n') + + for label, m in self.psbt.ux_notes: + msg.write('- %s: %s\n\n' % (label, m)) + if self.psbt.warnings: msg.write('\n---WARNING---\n\n') diff --git a/shared/psbt.py b/shared/psbt.py index 26d43b69..e2f2e1a2 100644 --- a/shared/psbt.py +++ b/shared/psbt.py @@ -5,6 +5,7 @@ from ustruct import unpack_from, unpack, pack from ubinascii import hexlify as b2a_hex from utils import xfp2str, B2A, keypath_to_str, problem_file_line +from utils import seconds2human_readable, datetime_from_timestamp, datetime_to_str import stash, gc, history, sys, ngu, ckcc, chains from uhashlib import sha256 from uio import BytesIO @@ -574,6 +575,32 @@ class psbtInputProxy(psbtProxy): self.parse(fd) + def has_relative_timelock(self, txin): + # https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki + SEQUENCE_LOCKTIME_DISABLE_FLAG = (1 << 31) + SEQUENCE_LOCKTIME_TYPE_FLAG = (1 << 22) + SEQUENCE_LOCKTIME_MASK = 0x0000ffff + SEQUENCE_LOCKTIME_GRANULARITY = 9 + is_timebased = False + + if txin.nSequence & SEQUENCE_LOCKTIME_DISABLE_FLAG: + # RTL disabled + return + if txin.nSequence & SEQUENCE_LOCKTIME_TYPE_FLAG: + # Time-based relative lock-time + is_timebased = True + res = (txin.nSequence & SEQUENCE_LOCKTIME_MASK) << SEQUENCE_LOCKTIME_GRANULARITY + else: + # Block height relative lock-time + res = txin.nSequence & SEQUENCE_LOCKTIME_MASK + + if res == 0: + # any locktime that is zero, regardless of MPT or blocks + # is always immediately spendable + return + + return is_timebased, res + def validate(self, idx, txin, my_xfp, parent): # Validate this txn input: given deserialized CTxIn and maybe witness @@ -963,6 +990,9 @@ class psbtObject(psbtProxy): self.active_multisig = None self.warnings = [] + # not a warning just more info about tx + # presented in UX on confirm tx screen before warnings + self.ux_notes = [] # v1 vs v2 validation self.is_v2 = False @@ -1238,6 +1268,74 @@ class psbtObject(psbtProxy): # we should not reach this point (ie. raise something to abort signing) return + def ux_relative_timelocks(self, tb, bb): + # visualize 10 largest timelock to user + # when signing a tx + MAX_SHOW = 10 + num_tb = len(tb) + num_bb = len(bb) + + if (num_tb + num_bb) > MAX_SHOW: + # 10 from each is enough for us to have in memory + tb = sorted(tb, key=lambda item: item[1], reverse=True)[:10] + bb = sorted(bb, key=lambda item: item[1], reverse=True)[:10] + if (num_tb >= 5) and (num_bb >= 5): + # 5 biggest from each + tb = tb[:5] + bb = bb[:5] + else: + if num_tb < num_bb: + tb = tb[:num_tb] + bb = bb[:(MAX_SHOW - num_tb)] + else: + bb = bb[:num_bb] + tb = tb[:(MAX_SHOW - num_bb)] + + if num_bb: + # Block height relative lock-time + if num_bb == 1: + idx, val = bb[0] + msg = "Input %d. has relative block height timelock of %d blocks" % ( + idx, val + ) + elif all(bb[0][1] == i[1] for i in bb): + msg = "%d inputs have relative block height timelock of %d blocks" % ( + num_bb, bb[0][1] + ) + else: + msg = "%d inputs have relative block height timelock." % num_bb + if num_bb > len(bb): + msg += " Showing only %d with highest values." % len(bb) + msg += "\n\n" + for idx, num_blocks in bb: + msg += " %d. %d blocks\n" % (idx, num_blocks) + msg += "\n" + + self.ux_notes.append(("Block height RTL", msg)) + + if num_tb: + # Block height relative lock-time + if num_tb == 1: + idx, val = tb[0] + val = seconds2human_readable(val) + msg = "Input %d. has relative time-based timelock of:\n %s" % ( + idx, val + ) + elif all(tb[0][1] == i[1] for i in tb): + msg = "%d inputs have relative time-based timelock of:\n %s" % ( + num_tb, seconds2human_readable(tb[0][1]) + ) + else: + msg = "%d inputs have relative time-based timelock." % num_tb + if num_tb > len(tb): + msg += " Showing only %d with highest values." % len(tb) + msg += "\n\n" + for idx, seconds in tb: + hr = seconds2human_readable(seconds) + msg += " %d. %s\n" % (idx, hr) + msg += "\n" + + self.ux_notes.append(("Time-based RTL", msg)) async def validate(self): # Do a first pass over the txn. Raise assertions, be terse tho because @@ -1278,6 +1376,11 @@ class psbtObject(psbtProxy): assert out.amount is None assert out.script is None + # time based relative locks + tb_rel_locks = [] + # block height based relative locks + bb_rel_locks = [] + smallest_nsequence = 0xffffffff # this parses the input TXN in-place for idx, txin in self.input_iter(): inp = self.inputs[idx] @@ -1298,6 +1401,39 @@ class psbtObject(psbtProxy): assert inp.req_height_locktime is None self.inputs[idx].validate(idx, txin, self.my_xfp, self) + if self.txn_version >= 2: + has_rtl = self.inputs[idx].has_relative_timelock(txin) + if has_rtl: + if has_rtl[0]: + tb_rel_locks.append((idx, has_rtl[1])) + else: + bb_rel_locks.append((idx, has_rtl[1])) + + if txin.nSequence < smallest_nsequence: + smallest_nsequence = txin.nSequence + + if isinstance(self.lock_time, int) and self.lock_time > 0: + if smallest_nsequence == 0xffffffff: + self.warnings.append(( + "Bad Locktime", + "Locktime has no effect! None of the nSequences decremented." + )) + else: + msg = "This tx can only be spent after " + if self.lock_time < 500000000: + msg += "block height of %d" % self.lock_time + else: + try: + dt = datetime_from_timestamp(self.lock_time) + msg += datetime_to_str(dt) + except: + msg += "%d (unix timestamp)" % self.lock_time + + msg += " (MTP)" # median time past + self.ux_notes.append(("Abs Locktime", msg)) + + # create UX for users about tx level relative timelocks (nSequence) + self.ux_relative_timelocks(tb_rel_locks, bb_rel_locks) assert len(self.inputs) == self.num_inputs, 'ni mismatch' diff --git a/shared/utils.py b/shared/utils.py index d841bbcc..d58b7eba 100644 --- a/shared/utils.py +++ b/shared/utils.py @@ -2,7 +2,7 @@ # # utils.py - Misc utils. My favourite kind of source file. # -import gc, sys, ustruct, ngu, chains, ure +import gc, sys, ustruct, ngu, chains, ure, time from ubinascii import unhexlify as a2b_hex from ubinascii import hexlify as b2a_hex from ubinascii import a2b_base64, b2a_base64 @@ -552,4 +552,39 @@ def pad_raw_secret(raw_sec_str): raw[0:len(x)] = x return raw +def seconds2human_readable(s): + days = s // (3600 * 24) + hours = s % (3600 * 24) // 3600 + minutes = (s % 3600) // 60 + seconds = (s % 3600) % 60 + msg = [] + if days: + msg.append("%dd" % days) + if hours: + msg.append("%dh" % hours) + if minutes: + msg.append("%dm" % minutes) + if seconds: + msg.append("%ds" % seconds) + + return " ".join(msg) + +def datetime_from_timestamp(ts): + gm_t = time.gmtime(0) + if gm_t[0] == 1970: + # unix + epoch_sub = 0 + elif gm_t[0] == 2000: + # stm32 + epoch_sub = 946684800 + else: + assert False + + return time.gmtime(ts - epoch_sub) + +def datetime_to_str(dt, fmt="%d-%02d-%02d %02d:%02d:%02d"): + y, mo, d, h, mi, s = dt[:6] + dts = fmt % (y, mo, d, h, mi, s) + return dts + " UTC" + # EOF diff --git a/testing/devtest/clear_seed.py b/testing/devtest/clear_seed.py index 443e9e22..72ee3ede 100644 --- a/testing/devtest/clear_seed.py +++ b/testing/devtest/clear_seed.py @@ -10,6 +10,7 @@ from sim_settings import sim_defaults if not pa.is_secret_blank(): # clear settings associated with this key, since it will be no more settings.current = dict(sim_defaults) + settings.nvram_key = bytes(32) pa.tmp_value = None # save a blank secret (all zeros is a special case, detected by bootloader) @@ -21,7 +22,10 @@ if not pa.is_secret_blank(): pa.login() assert pa.is_secret_blank() + settings.blank() +settings.master_sv_data = {} +settings.master_nvram_key = None # reset top menu and go there from actions import goto_top_menu goto_top_menu() diff --git a/testing/helpers.py b/testing/helpers.py index 5cdb9497..f3d5a95c 100644 --- a/testing/helpers.py +++ b/testing/helpers.py @@ -258,4 +258,22 @@ def detruncate_address(s): start, end = s.split('⋯') return start, end +def seconds2human_readable(s): + # duplicate from shared/utils.py - needed for tests + days = s // (3600 * 24) + hours = s % (3600 * 24) // 3600 + minutes = (s % 3600) // 60 + seconds = (s % 3600) % 60 + msg = "" + if days: + msg += "%dd" % days + if hours: + msg += " %dh" % hours + if minutes: + msg += " %dm" % minutes + if seconds: + msg += " %ds" % seconds + + return msg + # EOF diff --git a/testing/test_ephemeral.py b/testing/test_ephemeral.py index 2e69a2b3..11e34076 100644 --- a/testing/test_ephemeral.py +++ b/testing/test_ephemeral.py @@ -1292,7 +1292,6 @@ def test_tmp_upgrade_disabled(reset_seed_words, need_keypress, pick_menu_item, m = cap_menu() assert "Upgrade Firmware" in m node = BIP32Node.from_master_secret(os.urandom(32), netcode="XTN") - xfp = node.fingerprint().hex().upper() k0 = node.hwif(as_private=True) import_ephemeral_xprv("sd", extended_key=k0, seed_vault=True, from_main=True) goto_home() diff --git a/testing/test_seed_xor.py b/testing/test_seed_xor.py index 999074eb..6b4ecb7c 100644 --- a/testing/test_seed_xor.py +++ b/testing/test_seed_xor.py @@ -52,7 +52,8 @@ def restore_seed_xor(set_seed_words, goto_home, pick_menu_item, cap_story, choose_by_word_length, need_keypress, get_secrets, word_menu_entry, verify_ephemeral_secret_ui, confirm_tmp_seed, seed_vault_enable): - def doit(parts, expect, incl_self=False, save_to_vault=False): + def doit(parts, expect, incl_self=False, save_to_vault=False, + is_master_tmp_fail=False): if expect is None: parts, expect = prepare_test_pairs(*parts) @@ -111,7 +112,15 @@ def restore_seed_xor(set_seed_words, goto_home, pick_menu_item, cap_story, assert 'ZERO WARNING' in body need_keypress('2') - confirm_tmp_seed(seedvault=save_to_vault) + try: + confirm_tmp_seed(seedvault=save_to_vault) + except AssertionError as e: + if is_master_tmp_fail: + assert "Cannot use master seed as temporary" in str(e) + return + else: + raise + verify_ephemeral_secret_ui(mnemonic=expect.split(" "), seed_vault=save_to_vault) assert get_secrets()['mnemonic'] == expect @@ -136,15 +145,6 @@ def restore_seed_xor(set_seed_words, goto_home, pick_menu_item, cap_story, 'save saddle indicate embrace detail weasel spread life staff mushroom bicycle light', 'unlock damp injury tape enhance pause sheriff onion valley panic finger moon'], 'drama jeans craft mixture filter lamp invest suggest vacant neutral history swim'), - ([zero32]*2, zero32), - ([zero24]*2, zero24), - ([zero16]*2, zero16), - ([ones32]*7, ones32), - ([ones24]*7, ones24), - ([ones16]*7, ones16), - ([ones32]*4, zero32), - ([ones24]*4, zero24), - ([ones16]*4, zero16), # random generated *random_test_cases() ]) @@ -152,6 +152,24 @@ def test_import_xor(seed_vault, incl_self, parts, expect, restore_seed_xor): restore_seed_xor(parts, expect, incl_self, seed_vault) +@pytest.mark.parametrize('incl_self', [False, True]) +@pytest.mark.parametrize("parts, expect", [ + ([zero32] * 2, zero32), + ([zero24] * 2, zero24), + ([zero16] * 2, zero16), + ([ones32] * 7, ones32), + ([ones24] * 7, ones24), + ([ones16] * 7, ones16), + ([ones32] * 4, zero32), + ([ones24] * 4, zero24), + ([ones16] * 4, zero16), +]) +def test_import_xor_zeros_ones(incl_self, parts, expect, restore_seed_xor): + restore_seed_xor(parts, expect, incl_self, False, + is_master_tmp_fail=True if incl_self else False) + + + @pytest.mark.parametrize('num_words', [12, 18, 24]) @pytest.mark.parametrize('qty', [2, 3, 4]) @pytest.mark.parametrize('trng', [False, True]) diff --git a/testing/test_sign.py b/testing/test_sign.py index 1b5f5a96..30fc427e 100644 --- a/testing/test_sign.py +++ b/testing/test_sign.py @@ -3,7 +3,7 @@ # Transaction Signing. Important. # -import time, pytest, os, random, pdb, struct, base64, binascii, itertools +import time, pytest, os, random, pdb, struct, base64, binascii, itertools, datetime from ckcc_protocol.protocol import CCProtocolPacker, CCProtoError, MAX_TXN_LEN, CCUserRefused from binascii import b2a_hex, a2b_hex from psbt import BasicPSBT, BasicPSBTInput, BasicPSBTOutput, PSBT_IN_REDEEM_SCRIPT @@ -12,13 +12,16 @@ from pprint import pprint, pformat from decimal import Decimal from base64 import b64encode, b64decode from helpers import B2A, U2SAT, prandom, fake_dest_addr, make_change_addr, parse_change_back -from helpers import xfp2str +from helpers import xfp2str, seconds2human_readable from pycoin.key.BIP32Node import BIP32Node from constants import ADDR_STYLES, ADDR_STYLES_SINGLE, SIGHASH_MAP from txn import * from ckcc_protocol.constants import STXN_FINALIZE, STXN_VISUALIZE, STXN_SIGNED +SEQUENCE_LOCKTIME_TYPE_FLAG = (1 << 22) + + @pytest.mark.parametrize('finalize', [ False, True ]) def test_sign1(dev, need_keypress, finalize): in_psbt = a2b_hex(open('data/p2pkh-in-scriptsig.psbt', 'rb').read()) @@ -628,8 +631,7 @@ def test_change_fraud_path(start_sign, use_regtest, end_sign, case, check_agains _, story = cap_story() assert chg_addr in story assert 'Change back:' not in story - - signed = end_sign(True) + end_sign(True) @pytest.mark.bitcoind def test_change_fraud_addr(start_sign, end_sign, use_regtest, check_against_bitcoind, cap_story): @@ -714,8 +716,7 @@ def test_change_p2sh_p2wpkh(start_sign, end_sign, check_against_bitcoind, use_re #print(story) assert expect_addr in story assert parse_change_back(story) == (Decimal('1.09997082'), [expect_addr]) - - signed = end_sign(True) + end_sign(True) def test_sign_multisig_partial_fail(start_sign, end_sign): @@ -2217,4 +2218,529 @@ def test_psbt_v2_global_quantities(way, fake_txn, start_sign, end_sign, cap_stor title, story = cap_story() assert "failed" in story or "Invalid PSBT" in story or "Network fee bigger" in story + +@pytest.mark.bitcoind +@pytest.mark.parametrize("locktime", [ + 0, # zero default + False, # current block height + 800000, + 1513209600, # 2017-12-14 00:00:00 + 1387324800, # 2013-12-18 00:00:00 + 1294790399, # 2011-11-01 23:59:59 + 1748671747, # 2025-05-31 07:09:07 +]) +def test_locktime_ux(use_regtest, bitcoind_d_sim_watch, start_sign, end_sign, + microsd_path, cap_story, goto_home, need_keypress, + pick_menu_item, bitcoind, locktime): + use_regtest() + sim = bitcoind_d_sim_watch + addr = sim.getnewaddress() + bitcoind.supply_wallet.sendtoaddress(addr, 2) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + bi = sim.getblockchaininfo() + blocks = bi["blocks"] + + success = True + if locktime is False: + # current height - allowed + locktime = blocks + + if locktime < 500000000: + # blocks + if locktime > blocks: + success = False + else: + # MTP + if locktime > datetime.datetime.utcnow().timestamp(): + success = False + + dest_addr = sim.getnewaddress() # self-spend + psbt_resp = sim.walletcreatefundedpsbt([], [{dest_addr: 1.0}], locktime, {"fee_rate": 20}) + psbt = psbt_resp.get("psbt") + psbt_fname = "locktime.psbt" + with open(microsd_path(psbt_fname), "w") as f: + f.write(psbt) + goto_home() + pick_menu_item('Ready To Sign') + title, story = cap_story() + if "Choose PSBT file to be signed" in story: + need_keypress("y") + pick_menu_item(psbt_fname) + time.sleep(0.1) + title, story = cap_story() + + assert "WARNING" not in story + if locktime != 0: + assert "LOCKTIMES" in story + assert "Abs Locktime" in story + if locktime < 500000000: + assert f"This tx can only be spent after block height of {locktime}" in story + else: + dt = datetime.datetime.utcfromtimestamp(locktime) + ux_dt = dt.strftime("%Y-%m-%d %H:%M:%S") + assert f"This tx can only be spent after {ux_dt} UTC (MTP)" in story + # assert f"This tx can only be spent after {locktime} (unix timestamp)" in story + else: + assert "LOCKTIMES" not in story + + need_keypress("y") # confirm signing + time.sleep(0.1) + title, story = cap_story() + assert title == 'PSBT Signed' + assert "Updated PSBT is:" in story + assert "Finalized transaction (ready for broadcast)" in story + assert "TXID" in story + split_story = story.split("\n\n") + story_txid = split_story[-1].split("\n")[-1] + signed_psbt_fname = split_story[1] + with open(microsd_path(signed_psbt_fname), "r") as f: + signed_psbt = f.read().strip() + signed_txn_fname = split_story[3] + with open(microsd_path(signed_txn_fname), "r") as f: + signed_txn = f.read().strip() + assert signed_psbt != psbt + finalize_res = sim.finalizepsbt(signed_psbt) + bitcoind_signed_txn = finalize_res["hex"] + assert finalize_res["complete"] is True + accept_res = sim.testmempoolaccept([bitcoind_signed_txn])[0] + assert accept_res["allowed"] is success + assert signed_txn == bitcoind_signed_txn + if success: + txid = sim.sendrawtransaction(signed_txn) + else: + with pytest.raises(Exception): + sim.sendrawtransaction(signed_txn) + txid = accept_res["txid"] + assert len(txid) == 64 + assert txid == story_txid + + +@pytest.mark.bitcoind +@pytest.mark.parametrize("num_ins", [1, 4, 11]) +@pytest.mark.parametrize("differ", [True, False]) +@pytest.mark.parametrize("sequence", [0, 1, 50, 65534]) +def test_nsequence_blockheight_relative_locktime_ux(sequence, use_regtest, bitcoind_d_sim_watch, + start_sign, end_sign, microsd_path, cap_story, + goto_home, need_keypress, pick_menu_item, + bitcoind, num_ins, differ): + if differ and (sequence == 0): + # this case makes no sense + return + + use_regtest() + sim = bitcoind_d_sim_watch + sim.keypoolrefill(20) + for i in range(num_ins): + addr = sim.getnewaddress() + bitcoind.supply_wallet.sendtoaddress(addr, 1) + + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + dest_addr = sim.getnewaddress() # self-spend + utxos = sim.listunspent() + assert len(utxos) == num_ins + + ins = [] + num_ins_locked = 0 + locks = [] + for i, utxo in enumerate(utxos): + confirmations = utxo["confirmations"] + lock = (confirmations + sequence) if sequence else 0 + if i and differ: + # not first one (0th) as it should have sequence provided via parametrize + # all others decremented by iteration count + nSeq = lock - i + if nSeq < 0: + nSeq = 0 + else: + nSeq = lock + + if nSeq > 0: + num_ins_locked += 1 + locks.append(nSeq) + + # block height based RTL + inp = { + "txid": utxo["txid"], + "vout": utxo["vout"], + "sequence": nSeq, + } + ins.append(inp) + + psbt_resp = sim.walletcreatefundedpsbt(ins, [{dest_addr: (num_ins - 0.1)}], 0, {"fee_rate": 20}) + psbt = psbt_resp.get("psbt") + psbt_fname = "rtl-blockheight.psbt" + with open(microsd_path(psbt_fname), "w") as f: + f.write(psbt) + goto_home() + pick_menu_item('Ready To Sign') + title, story = cap_story() + if "Choose PSBT file to be signed" in story: + need_keypress("y") + pick_menu_item(psbt_fname) + time.sleep(0.1) + title, story = cap_story() + + assert "WARNING" not in story + if sequence: + assert "TX LOCKTIMES" in story + assert "Block height RTL" in story + if num_ins_locked == 1: + assert ("has relative block height timelock of %d" % lock) in story + else: + if differ: + assert ("%d inputs have relative block height timelock." % num_ins_locked) in story + for i in range(num_ins_locked): + if not (("%d. " % i) in story): + assert "only 10 with highest values" in story + else: + assert ("%d inputs have relative block height timelock of %d" % (num_ins_locked, lock)) in story + else: + assert "TX LOCKTIMES" not in story + + need_keypress("y") # confirm signing + time.sleep(0.1) + title, story = cap_story() + assert title == 'PSBT Signed' + assert "Updated PSBT is:" in story + assert "Finalized transaction (ready for broadcast)" in story + assert "TXID" in story + split_story = story.split("\n\n") + story_txid = split_story[-1].split("\n")[-1] + signed_psbt_fname = split_story[1] + with open(microsd_path(signed_psbt_fname), "r") as f: + signed_psbt = f.read().strip() + signed_txn_fname = split_story[3] + with open(microsd_path(signed_txn_fname), "r") as f: + signed_txn = f.read().strip() + assert signed_psbt != psbt + finalize_res = sim.finalizepsbt(signed_psbt) + bitcoind_signed_txn = finalize_res["hex"] + assert finalize_res["complete"] is True + accept_res = sim.testmempoolaccept([bitcoind_signed_txn])[0] + if sequence == 0: + assert accept_res["allowed"] + return + + assert accept_res["allowed"] is False + assert accept_res["reject-reason"] == 'non-BIP68-final' + if sequence > 50: + # not gonna mine 65k blocks + return + sim.generatetoaddress(sequence, bitcoind.supply_wallet.getnewaddress()) # mine N blocks + accept_res = sim.testmempoolaccept([bitcoind_signed_txn])[0] + assert accept_res["allowed"] is True + assert signed_txn == bitcoind_signed_txn + txid = sim.sendrawtransaction(signed_txn) + assert len(txid) == 64 + assert txid == story_txid + + +@pytest.mark.bitcoind +@pytest.mark.veryslow +@pytest.mark.parametrize("num_ins", [1, 4, 11]) +@pytest.mark.parametrize("differ", [True, False]) +@pytest.mark.parametrize("seconds", [512, 10000, 1000000, 33554431]) +def test_nsequence_timebased_relative_locktime_ux(seconds, use_regtest, bitcoind_d_sim_watch, start_sign, + microsd_path, cap_story, goto_home, need_keypress, + pick_menu_item, bitcoind, end_sign, num_ins, differ): + sequence = SEQUENCE_LOCKTIME_TYPE_FLAG | (seconds >> 9) + use_regtest() + sim = bitcoind_d_sim_watch + sim.keypoolrefill(20) + for i in range(num_ins): + addr = sim.getnewaddress() + bitcoind.supply_wallet.sendtoaddress(addr, 1) + + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + dest_addr = sim.getnewaddress() # self-spend + utxos = sim.listunspent() + assert len(utxos) == num_ins + + bi = sim.getblockchaininfo() + + ins = [] + num_ins_locked = 0 + for i, utxo in enumerate(utxos): + # time-based RTL + if i and differ: + nSeq = sequence - (sequence * i) + if nSeq < 0: + nSeq = 0 + + else: + nSeq = sequence + + if nSeq > 0: + num_ins_locked += 1 + + inp = { + "txid": utxo["txid"], + "vout": utxo["vout"], + "sequence": nSeq, + } + ins.append(inp) + + psbt_resp = sim.walletcreatefundedpsbt(ins, [{dest_addr: (num_ins - 0.1)}], 0, {"fee_rate": 20}) + psbt = psbt_resp.get("psbt") + psbt_fname = "rtl-time.psbt" + with open(microsd_path(psbt_fname), "w") as f: + f.write(psbt) + goto_home() + pick_menu_item('Ready To Sign') + title, story = cap_story() + if "Choose PSBT file to be signed" in story: + need_keypress("y") + pick_menu_item(psbt_fname) + time.sleep(0.1) + title, story = cap_story() + + assert "WARNING" not in story + assert "TX LOCKTIMES" in story + assert "Time-based RTL" in story + t_from_seq = (sequence & 0x0000ffff) << 9 + base_msg = "relative time-based timelock of:\n %s" % seconds2human_readable(t_from_seq) + if num_ins_locked == 1: + assert ("has " + base_msg) in story + else: + if differ: + assert ("%d inputs have relative time-based timelock." % num_ins_locked) in story + for i in range(num_ins_locked): + assert ("%d. " % i) in story + else: + msg1 = "%d inputs have " % num_ins_locked + assert (msg1 + base_msg) in story + + need_keypress("y") # confirm signing + time.sleep(0.1) + title, story = cap_story() + assert title == 'PSBT Signed' + assert "Updated PSBT is:" in story + assert "Finalized transaction (ready for broadcast)" in story + assert "TXID" in story + split_story = story.split("\n\n") + story_txid = split_story[-1].split("\n")[-1] + signed_psbt_fname = split_story[1] + with open(microsd_path(signed_psbt_fname), "r") as f: + signed_psbt = f.read().strip() + signed_txn_fname = split_story[3] + with open(microsd_path(signed_txn_fname), "r") as f: + signed_txn = f.read().strip() + assert signed_psbt != psbt + finalize_res = sim.finalizepsbt(signed_psbt) + bitcoind_signed_txn = finalize_res["hex"] + assert finalize_res["complete"] is True + accept_res = sim.testmempoolaccept([bitcoind_signed_txn])[0] + assert accept_res["allowed"] is False + assert accept_res["reject-reason"] == 'non-BIP68-final' + if seconds > 512: + # not gonna wait for it + return + # mine blocks - mining increases the timestamp but somehow randomly + while True: + sim.generatetoaddress(5, bitcoind.supply_wallet.getnewaddress()) + t = sim.getblockchaininfo()["time"] + if (t - bi["time"]) > 600: + break + accept_res = sim.testmempoolaccept([bitcoind_signed_txn])[0] + assert accept_res["allowed"] is True + assert signed_txn == bitcoind_signed_txn + txid = sim.sendrawtransaction(signed_txn) + assert len(txid) == 64 + assert txid == story_txid + + +@pytest.mark.bitcoind +@pytest.mark.veryslow +@pytest.mark.parametrize("abs_lock", [True, False]) +@pytest.mark.parametrize("num_rtl", [(2,3),(4,7),(8,3),(6,7)]) +def test_mixed_locktimes(num_rtl, use_regtest, bitcoind_d_sim_watch, start_sign, + microsd_path, cap_story, goto_home, need_keypress, + pick_menu_item, bitcoind, end_sign, abs_lock): + tb, bb = num_rtl + num_ins = tb + bb + sequence = SEQUENCE_LOCKTIME_TYPE_FLAG | (512 >> 9) + use_regtest() + sim = bitcoind_d_sim_watch + sim.keypoolrefill(20) + for i in range(num_ins): + addr = sim.getnewaddress() + bitcoind.supply_wallet.sendtoaddress(addr, 1) + + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + dest_addr = sim.getnewaddress() # self-spend + utxos = sim.listunspent() + assert len(utxos) == num_ins + + bi = sim.getblockchaininfo() + blocks = bi["blocks"] + if abs_lock: + # absolute locktime smaller then relative + locktime = blocks + 10 + else: + locktime = 0 + + ins = [] + for i, utxo in enumerate(utxos): + # time-based RTL + if i < tb: + nSeq = sequence + else: + confirmations = utxo["confirmations"] + nSeq = confirmations + 20 # blocks + + inp = { + "txid": utxo["txid"], + "vout": utxo["vout"], + "sequence": nSeq, + } + ins.append(inp) + + psbt_resp = sim.walletcreatefundedpsbt(ins, [{dest_addr: (num_ins - 0.1)}], locktime, {"fee_rate": 20}) + psbt = psbt_resp.get("psbt") + psbt_fname = "rtl-mixin-time.psbt" + with open(microsd_path(psbt_fname), "w") as f: + f.write(psbt) + goto_home() + pick_menu_item('Ready To Sign') + title, story = cap_story() + if "Choose PSBT file to be signed" in story: + need_keypress("y") + pick_menu_item(psbt_fname) + time.sleep(0.1) + title, story = cap_story() + + assert "WARNING" not in story + assert "TX LOCKTIMES" in story + assert "Time-based RTL" in story + t_from_seq = (sequence & 0x0000ffff) << 9 + base_msg = "relative time-based timelock of:\n %s" % seconds2human_readable(t_from_seq) + msg1 = "%d inputs have " % tb + assert (msg1 + base_msg) in story + assert "Block height RTL" in story + assert ("%d inputs have relative block height timelock of %d" % (bb, 21)) in story + + if abs_lock: + assert "Abs Locktime" in story + assert f"This tx can only be spent after block height of {locktime}" in story + else: + assert "Abs Locktime" not in story + + need_keypress("y") # confirm signing + time.sleep(0.1) + title, story = cap_story() + assert title == 'PSBT Signed' + assert "Updated PSBT is:" in story + assert "Finalized transaction (ready for broadcast)" in story + assert "TXID" in story + split_story = story.split("\n\n") + story_txid = split_story[-1].split("\n")[-1] + signed_psbt_fname = split_story[1] + with open(microsd_path(signed_psbt_fname), "r") as f: + signed_psbt = f.read().strip() + signed_txn_fname = split_story[3] + with open(microsd_path(signed_txn_fname), "r") as f: + signed_txn = f.read().strip() + assert signed_psbt != psbt + finalize_res = sim.finalizepsbt(signed_psbt) + bitcoind_signed_txn = finalize_res["hex"] + assert finalize_res["complete"] is True + accept_res = sim.testmempoolaccept([bitcoind_signed_txn])[0] + assert accept_res["allowed"] is False + + if abs_lock: + assert accept_res["reject-reason"] == 'non-final' + else: + assert accept_res["reject-reason"] == 'non-BIP68-final' + + # try to mine 21 blocks - which should unlock height based inpputs + # and also absolute timelock which is smaller than relative + # but tx must be still unspendable as time based are still locked + sim.generatetoaddress(21, bitcoind.supply_wallet.getnewaddress()) + accept_res = sim.testmempoolaccept([bitcoind_signed_txn])[0] + assert accept_res["allowed"] is False + assert accept_res["reject-reason"] == 'non-BIP68-final' + + # mine blocks - mining increases the timestamp but somehow randomly + while True: + sim.generatetoaddress(5, bitcoind.supply_wallet.getnewaddress()) + t = sim.getblockchaininfo()["time"] + if (t - bi["time"]) > 600: + break + accept_res = sim.testmempoolaccept([bitcoind_signed_txn])[0] + assert accept_res["allowed"] is True + assert signed_txn == bitcoind_signed_txn + txid = sim.sendrawtransaction(signed_txn) + assert len(txid) == 64 + assert txid == story_txid + +def random_nLockTime_test_cases(num=10): + res = [] + now = datetime.datetime.utcnow() + for i in range(num): + td = datetime.timedelta(days=i, hours=i+i, seconds=7**i) + var = now + td + var = var.replace(tzinfo=datetime.timezone.utc) + res.append((int(var.timestamp()), var.strftime("%Y-%m-%d %H:%M:%S"))) + return res + + +@pytest.mark.parametrize("nLockTime", [ + (1513209600, "2017-12-14 00:00:00"), + (1387324800, "2013-12-18 00:00:00"), + (1294790399, "2011-01-11 23:59:59"), + (1748671747, "2025-05-31 06:09:07"), + *random_nLockTime_test_cases() +]) +def test_timelocks_visualize(start_sign, end_sign, dev, bitcoind, use_regtest, + bitcoind_d_sim_watch, nLockTime): + # - works on simulator and connected USB real-device + nLockTime, expect_ux = nLockTime + num_ins = 10 + use_regtest() + bitcoind_d_sim_watch.keypoolrefill(20) + for i in range(num_ins): + addr = bitcoind_d_sim_watch.getnewaddress() + bitcoind.supply_wallet.sendtoaddress(addr, 1) + + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + dest_addr = bitcoind_d_sim_watch.getnewaddress() # self-spend + utxos = bitcoind_d_sim_watch.listunspent() + assert len(utxos) == num_ins + + ins = [] + for i, utxo in enumerate(utxos): + if i % 2 == 0: + nSeq = (SEQUENCE_LOCKTIME_TYPE_FLAG | i) + else: + confirmations = utxo["confirmations"] + nSeq = confirmations + (20*i) + + inp = { + "txid": utxo["txid"], + "vout": utxo["vout"], + "sequence": nSeq, + } + ins.append(inp) + + psbt_resp = bitcoind_d_sim_watch.walletcreatefundedpsbt( + ins, [{dest_addr: (num_ins - 0.1)}], + nLockTime, {"fee_rate": 20} + ) + psbt = base64.b64decode(psbt_resp.get("psbt")) + + open('debug/locktimes.psbt', 'wb').write(psbt) + + # should be able to sign, but get warning + + # use new feature to have Coldcard return the 'visualization' of transaction + start_sign(psbt, False, stxn_flags=STXN_VISUALIZE) + story = end_sign(accept=None, expect_txn=False) + + story = story.decode('ascii') + assert datetime.datetime.utcfromtimestamp(nLockTime).strftime("%Y-%m-%d %H:%M:%S") == expect_ux + assert f"Abs Locktime: This tx can only be spent after {expect_ux} UTC (MTP)" in story + assert "Block height RTL: 5 inputs have relative block height timelock" in story + # when i=0 in loop time based RTL is zero + assert "Time-based RTL: 4 inputs have relative time-based timelock" in story + # EOF From 79c143b7eb84e23dffa90803a3165fca7f2847a7 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Sat, 9 Dec 2023 16:27:31 +0100 Subject: [PATCH 09/52] Remove legacy Mk1-3 code from pin changing --- releases/ChangeLog.md | 1 + shared/actions.py | 158 +++++++----------------------------------- shared/flow.py | 4 +- 3 files changed, 28 insertions(+), 135 deletions(-) diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index 5d2892bf..7f320a1e 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -15,6 +15,7 @@ (rather than 24 words). - Enhancement: One instant retry on SE1 comm failures - Enhancement: Allow passphrase via USB if passphrase already set - operates on master seed. +- Enhancement: Continuation of removal of obsolete Mk2/Mk3 code-paths from master branch. - Bugfix: Handle any failures in slot reading when loading settings - Bugfix: Add missing First Time UX for extended key import as master seed - Bugfix: Hide `Upgrade Firmware` menu item if temporary seed is active diff --git a/shared/actions.py b/shared/actions.py index 3bc9172c..97265ca8 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -1913,155 +1913,65 @@ async def verify_sig_file(*a): await verify_txt_sig_file(fn) -async def pin_changer(_1, _2, item): - # Help them to change pins with appropriate warnings. - # - forcing them to drill-down to get warning about secondary is on purpose - # - the bootloader maybe lying to us about weather we are main vs. duress - # - there is a duress wallet for both main/sec pins, and you need to know main pin for that(mk3) +async def main_pin_changer(*a): + # Help them to change the main (true) PIN with appropriate warnings. + # - the bootloader maybe lying to us about main vs trick pin # - what may look like just policy here, is in fact enforced by the bootrom code # from glob import dis from login import LoginUX - from pincodes import BootloaderError, EPIN_OLD_AUTH_FAIL - - mode = item.arg - - # NOTE: for mk4, only "main" is applicable. - - warn = {'main': ('Main PIN', - 'You will be changing the main PIN used to unlock your Coldcard. ' - "It's the one you just used a moment ago to get in here."), - 'duress': ('Duress PIN', - 'This PIN leads to a bogus wallet. Funds are recoverable ' - 'from main seed backup, but not as easily.'), - 'secondary': ('Second PIN', - 'This PIN protects the "secondary" wallet that can be used to ' - 'segregate funds or other banking purposes. This other wallet is ' - 'completely independant of the primary.'), - 'brickme': ('Brickme PIN', - 'Use of this special PIN code at any prompt will destroy the ' - 'Coldcard completely. It cannot be reused or salvaged, and ' - 'the secrets it held are destroyed forever.\n\nDO NOT TEST THIS!'), - } - - if pa.is_secondary: - # secondary wallet user can only change their own password, and the secondary - # duress pin... - # - now excluded from menu, but keep for Mark1/2 hardware! - if mode == 'main' or mode == 'brickme': - await needs_primary() - return - - if mode == 'duress' and pa.is_secret_blank(): - await ux_show_story("Please set wallet seed before creating duress wallet.") - return - - # are we changing the pin used to login? - is_login_pin = (mode == 'main') or (mode == 'secondary' and pa.is_secondary) + from pincodes import EPIN_OLD_AUTH_FAIL lll = LoginUX() - lll.offer_second = False - title, msg = warn[mode] + title = 'Main PIN' + msg = '''\ +You will be changing the main PIN used to unlock your Coldcard. - async def incorrect_pin(): - await ux_show_story('You provided an incorrect value for the existing %s.' % title, - title='Wrong PIN') - return - - # standard threats for all PIN's - msg += '''\n\n\ -THERE IS ABSOLUTELY NO WAY TO RECOVER A FORGOTTEN PIN! Write it down. - -We strongly recommend all PIN codes used be unique between each other. -''' - if not is_login_pin: - msg += '''\nUse 999999-999999 to clear existing PIN.''' +THERE IS ABSOLUTELY NO WAY TO RECOVER A FORGOTTEN PIN!\n +Write it down.''' ch = await ux_show_story(msg, title=title) if ch != 'y': return + async def incorrect_pin(): + await ux_show_story('You provided an incorrect value for the existing PIN.', + title='Wrong PIN') + return + args = {} - need_old_pin = True - - if is_login_pin: - # Challenge them for old password; they probably have it, and we have it - # in memory already, because we wouldn't be here otherwise... but - # challenge them anyway as a policy choice. - need_old_pin = True - else: - # There may be no existing PIN, and we need to learn that - - if mode == 'secondary': - args['is_secondary'] = True - - elif mode == 'duress': - - args['is_duress'] = True - - need_old_pin = bool(pa.has_duress_pin()) - - elif mode == 'brickme': - args['is_brickme'] = True - - need_old_pin = bool(pa.has_brickme_pin()) - - if need_old_pin and not version.has_608: - # Do an expensive check (mostly for secondary pin case?) - try: - dis.fullscreen("Check...") - pa.change(old_pin=b'', new_pin=b'', **args) - need_old_pin = False - except BootloaderError as exc: - # not an error: old pin in non-blank - need_old_pin = True - - if not need_old_pin: - # It is blank - old_pin = '' - else: - # We need the existing pin, so prompt for that. - lll.subtitle = 'Old ' + title - - old_pin = await lll.prompt_pin() - if old_pin is None: - return await ux_aborted() + # We need the existing pin, so prompt for that. + lll.subtitle = 'Old ' + title + old_pin = await lll.prompt_pin() + if old_pin is None: + return await ux_aborted() args['old_pin'] = old_pin.encode() # we can verify the main pin right away here. Be nice. - if is_login_pin and args['old_pin'] != pa.pin: + if args['old_pin'] != pa.pin: return await incorrect_pin() while 1: lll.reset() lll.subtitle = "New " + title - pin = await lll.get_new_pin(title, allow_clear=True) + pin = await lll.get_new_pin(title, allow_clear=False) if pin is None: return await ux_aborted() - is_clear = (pin == CLEAR_PIN) - - args['new_pin'] = pin.encode() if not is_clear else b'' - - if args['new_pin'] == pa.pin and not is_login_pin: - await ux_show_story("Your new PIN matches the existing PIN used to get here. " - "It would be a bad idea to use it for another purpose.", - title="Try Again") - continue - from trick_pins import tp prob = tp.check_new_main_pin(pin) if prob: await ux_show_story(prob, title="Try Again") continue + args['new_pin'] = pin.encode() break # install it. try: - dis.fullscreen("Clearing..." if is_clear else "Saving...") + dis.fullscreen("Saving PIN...") dis.busy_bar(True) pa.change(**args) @@ -2072,20 +1982,19 @@ We strongly recommend all PIN codes used be unique between each other. code = exc.args[1] if code == EPIN_OLD_AUTH_FAIL: - # likely: wrong old pin, on anything but main PIN + # unlikely: but maybe we got tricked? return await incorrect_pin() else: return await ux_show_story("Unexpected low-level error: %s" % exc.args[0], title='Error') # Main pin is changed, and we use it lots, so update pa - # - also we need pa.has_duress_pin() and has_brickme_pin() to be correct # - this step can be super slow with 608, unfortunately try: dis.fullscreen("Verify...") dis.busy_bar(True) - pa.setup(args['new_pin'] if is_login_pin else pa.pin, pa.is_secondary) + pa.setup(args['new_pin']) if not pa.is_successful(): # typical: do need login, but if we just cleared the main PIN, @@ -2096,23 +2005,6 @@ We strongly recommend all PIN codes used be unique between each other. from trick_pins import tp tp.main_pin_has_changed(pa.pin.decode()) - if mode == 'duress': - # program the duress secret now... it's derived from real wallet contents - from stash import SensitiveValues, SecretStash, AE_SECRET_LEN - - if is_clear: - # clear secret, using the new pin, which is empty string - pa.change(is_duress=True, new_secret=b'\0' * AE_SECRET_LEN, old_pin=b'') - else: - with SensitiveValues() as sv: - # derive required key - node, _ = sv.duress_root() - d_secret = SecretStash.encode(xprv=node) - sv.register(d_secret) - - # write it out. - pa.change(is_duress=True, new_secret=d_secret, old_pin=args['new_pin']) - finally: dis.busy_bar(False) diff --git a/shared/flow.py b/shared/flow.py index e8cb61e6..ba815bed 100644 --- a/shared/flow.py +++ b/shared/flow.py @@ -73,10 +73,10 @@ with the Coldcard.''', predicate=lambda: version.has_nfc), ] -# all pre-login values +# Mostly pre-login values here. LoginPrefsMenu = [ # xxxxxxxxxxxxxxxx - MenuItem('Change Main PIN', f=pin_changer, arg='main'), + MenuItem('Change Main PIN', f=main_pin_changer), MenuItem('Trick PINs', menu=trick_pin_menu), MenuItem('Set Nickname', f=pick_nickname), MenuItem('Scramble Keypad', f=pick_scramble), From 3e5fd573a6af3f5f46d826fa0a8013a5ee0772a9 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Wed, 8 Nov 2023 11:47:35 +0100 Subject: [PATCH 10/52] pwsave menu UX rework; do not allow empty bip39 passphrase --- releases/ChangeLog.md | 2 + shared/actions.py | 2 +- shared/pwsave.py | 169 +++++++++++++++++++++++++------------- shared/seed.py | 27 ++++-- testing/data/pwsave.tmp | Bin 0 -> 191 bytes testing/test_ephemeral.py | 4 +- testing/test_pwsave.py | 51 +++++++++--- 7 files changed, 172 insertions(+), 83 deletions(-) create mode 100644 testing/data/pwsave.tmp diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index 7f320a1e..a5e7e982 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -6,6 +6,7 @@ ([nLockTime](https://en.bitcoin.it/wiki/NLockTime), [nSequence](https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki)) when signing +- Enhancement: New submenu for saved BIP-39 Passphrases allowing to delete saved entries. - Enhancement: Add current temporary seed to Seed Vault from within Seed Vault menu. If current seed is temporary and not saved yet, `Add current tmp` menu item is shown in Seed Vault menu. @@ -20,6 +21,7 @@ - Bugfix: Add missing First Time UX for extended key import as master seed - Bugfix: Hide `Upgrade Firmware` menu item if temporary seed is active - Bugfix: Disallow using master seed as temporary seed +- Bugfix: Do not allow to `APPLY` empty BIP-39 passphrase ## 5.2.0 - 2023-10-10 diff --git a/shared/actions.py b/shared/actions.py index 97265ca8..9f13a474 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -972,7 +972,7 @@ async def restore_main_secret(*a): msg = "Restore main wallet and its settings?\n\n" if not in_seed_vault(pa.tmp_value): msg += ( - "Press OK to forget current temporary wallet " + "Press OK to forget current temporary seed " "settings, or press (1) to save & keep " "those settings if same seed is later restored." ) diff --git a/shared/pwsave.py b/shared/pwsave.py index 64c5ed9e..05746845 100644 --- a/shared/pwsave.py +++ b/shared/pwsave.py @@ -2,10 +2,11 @@ # # pwsave.py - Save bip39 passphrases into encrypted file on MicroSD (if desired) # -import stash, ujson, ngu, pyb +import stash, ujson, ngu, pyb, os from files import CardSlot, CardMissingError, needs_microsd from ux import ux_dramatic_pause, ux_confirm, ux_show_story from utils import xfp2str +from menu import MenuItem, MenuSystem class PassphraseSaver: @@ -16,7 +17,8 @@ class PassphraseSaver: def __init__(self): self.key = None - def filename(self, card): + @staticmethod + def filename(card): # Construct actual filename to use. # - some very minor obscurity, but we aren't relying on that. return card.get_sd_root() + '/.tmp.tmp' @@ -31,7 +33,6 @@ class PassphraseSaver: with stash.SensitiveValues(bypass_tmp=True) as sv: self.key = bytearray(sv.encryption_key(salt)) - def _read(self, card): # Return a list of saved passphrases, or empty list if fail. # Fail silently in all cases. Expect to see lots of noise here. @@ -46,9 +47,7 @@ class PassphraseSaver: except: return [] - - async def append(self, xfp, bip39pw): - # encrypt and save; always appends. + async def read_and_save(self): from glob import dis while 1: @@ -59,8 +58,7 @@ class PassphraseSaver: self._calc_key(card) data = self._read(card) if self.key else [] - - data.append(dict(xfp=xfp, pw=bip39pw)) + yield data # yield data that can be modified encrypt = ngu.aes.CTR(self.key) @@ -74,29 +72,112 @@ class PassphraseSaver: except CardMissingError: ch = await needs_microsd() - if ch == 'x': # undocumented, but needs escape route + if ch == 'x': # undocumented, but needs escape route break - - def make_menu(self): - from menu import MenuItem, MenuSystem + async def delete(self, idx): + c = self.read_and_save() + data = next(c) + del data[idx] + # resume generator - save + try: + next(c) + except StopIteration: pass + if not data: + return True + + async def append(self, xfp, bip39pw): + c = self.read_and_save() + data = next(c) + to_add = dict(xfp=xfp, pw=bip39pw) + if to_add not in data: + data.append(to_add) + # resume generator - save + try: + next(c) + except StopIteration: pass + + +class PassphraseSaverMenu(MenuSystem): + + def update_contents(self): + tmp = PassphraseSaverMenu.construct() + self.replace_items(tmp) + + @staticmethod + async def apply(menu, idx, item): + # apply the password immediately and drop them at top menu from actions import goto_top_menu from ux import ux_show_story from seed import set_bip39_passphrase + from pincodes import pa + from glob import settings + bypass_tmp = True + pw, expect_xfp = item.arg + if pa.tmp_value and settings.get("words", None): + xfp = settings.get("xfp", 0) + title = "[%s]" % xfp2str(xfp) + ch = await ux_show_story("Temporary seed is active. Press (1)" + " to add passphrase to the current active" + " temporary seed instead of the main seed.", + title=title, escape='1') + if ch == '1': + bypass_tmp = False + + applied = await set_bip39_passphrase(pw, bypass_tmp=bypass_tmp, + summarize_ux=False) + if not applied: + return + + xfp = settings.get('xfp') + + # verification step + if xfp == expect_xfp: + # feedback that it worked + await ux_show_story("Passphrase restored.", title="[%s]" % xfp2str(xfp)) + else: + got = xfp2str(xfp) + exp = xfp2str(expect_xfp) + await ux_show_story("XFP verification failed. Restored wallet XFP [%s] " + "does not match expected XFP [%s] from " + "saved passphrase file." % (got, exp)) + return + + goto_top_menu() + + @staticmethod + async def delete_entry(menu, idx, item): + from ux import the_ux + pw_saver, i = item.arg + if await ux_confirm("Delete saved passphrase?"): + is_empty = await pw_saver.delete(i) + the_ux.pop() + if not is_empty: + m = the_ux.top_of_stack() + m.update_contents() + else: + # remove .tmp.tmp file after last passphrase + # is deleted + with CardSlot() as card: + f_path = pw_saver.filename(card) + os.remove(f_path) + the_ux.pop() + m = the_ux.top_of_stack() + m.update_contents() + + @classmethod + def construct(cls): + # We have a list of xfp+pw fields. Make a menu. # Read file, decrypt and make a menu to show; OR return None # if any error hit. + pw_saver = PassphraseSaver() with CardSlot() as card: - - self._calc_key(card) - if not self.key: return None - - data = self._read(card) + pw_saver._calc_key(card) + data = pw_saver._read(card) if not data: return None - # We have a list of xfp+pw fields. Make a menu. - # Challenge: we need to hint at which is which, but don't want to # show the password on-screen. # - simple algo: @@ -118,46 +199,16 @@ class PassphraseSaver: # give up: show it all! parts = [i for i,_ in pws] - async def doit(menu, idx, item): - # apply the password immediately and drop them at top menu - from pincodes import pa - from glob import settings - - bypass_tmp = True - pw, expect_xfp = item.arg - if pa.tmp_value and settings.get("words", None): - xfp = settings.get("xfp", None) - title = "[%s]" % xfp2str(xfp) - ch = await ux_show_story("Temporary wallet is active. Press (1)" - " to add passphrase to the current active" - " temporary seed instead of the main seed.", - title=title, escape='1') - if ch == '1': - bypass_tmp = False - - applied = await set_bip39_passphrase(pw, bypass_tmp=bypass_tmp, - summarize_ux=False) - if not applied: - return - - xfp = settings.get('xfp') - - # verification step - if xfp == expect_xfp: - # feedback that it worked - await ux_show_story("Passphrase restored.", title="[%s]" % xfp2str(xfp)) - else: - got = xfp2str(xfp) - exp = xfp2str(expect_xfp) - await ux_show_story("XFP verification failed. Restored wallet XFP [%s] " - "does not match expected XFP [%s] from " - "saved passphrase file." % (got, exp)) - return - - goto_top_menu() - - - return MenuSystem((MenuItem(label or '(empty)', f=doit, arg=pw) for pw, label in zip(pws, parts))) + items = [] + for i, (pw, label) in enumerate(zip(pws, parts)): + xfp_ui = "[%s]" % xfp2str(pw[1]) + submenu = MenuSystem([ + MenuItem(xfp_ui), + MenuItem("Restore", f=cls.apply, arg=pw), + MenuItem("Delete", f=cls.delete_entry, arg=(pw_saver, i)), + ]) + items.append(MenuItem(label or "(empty)", menu=submenu)) + return items # # Support for using MicroSD as second factor to the login PIN. diff --git a/shared/seed.py b/shared/seed.py index ff8c1ba4..d07941ac 100644 --- a/shared/seed.py +++ b/shared/seed.py @@ -19,7 +19,7 @@ from ux import PressRelease, ux_input_numbers, ux_input_text, show_qr_code from actions import goto_top_menu from stash import SecretStash from ubinascii import hexlify as b2a_hex -from pwsave import PassphraseSaver +from pwsave import PassphraseSaver, PassphraseSaverMenu from glob import settings, dis from pincodes import pa from nvstore import SettingsObject @@ -1075,6 +1075,14 @@ class PassphraseMenu(MenuSystem): global pp_sofar pp_sofar = '' + items = self.construct() + super(PassphraseMenu, self).__init__(items) + + def update_contents(self): + tmp = self.construct() + self.replace_items(tmp) + + def construct(self): items = [ # xxxxxxxxxxxxxxxx MenuItem('Edit Phrase', f=self.view_edit_phrase), @@ -1090,18 +1098,18 @@ class PassphraseMenu(MenuSystem): with CardSlot() as card: # check if passphrases file exists on SD # if yes add menu item - if card.exists(PassphraseSaver().filename(card)): + if card.exists(PassphraseSaver.filename(card)): items.insert(0, MenuItem('Restore Saved', menu=self.restore_saved)) except: pass - super(PassphraseMenu, self).__init__(items) + return items @staticmethod async def restore_saved(*a): dis.fullscreen("Decrypting...") try: - menu = PassphraseSaver().make_menu() + items = PassphraseSaverMenu.construct() except CardMissingError: await needs_microsd() return @@ -1109,11 +1117,11 @@ class PassphraseMenu(MenuSystem): await ux_show_story(title="Failure", msg=str(e) + problem_file_line(e)) return - if not menu: + if not items: await ux_show_story("Nothing found") return - return menu + return PassphraseSaverMenu(items) def on_cancel(self): # zip to cancel item when they fail to exit via X button @@ -1177,12 +1185,15 @@ class PassphraseMenu(MenuSystem): goto_top_menu() async def done_apply(self, *a): - # apply the passphrase. - # - important to work on empty string here too. + # apply the passphrase import stash from glob import settings from pincodes import pa + if not pp_sofar: + # empty string here - noop + return + nv, xfp, parent_xfp = await calc_bip39_passphrase(pp_sofar, bypass_tmp=True) msg = ('Above is the master key fingerprint of the new wallet. ' diff --git a/testing/data/pwsave.tmp b/testing/data/pwsave.tmp new file mode 100644 index 0000000000000000000000000000000000000000..8b79c73caf9496a9c91ef78feca7d65dd2c41416 GIT binary patch literal 191 zcmV;w06_on$^QWJH)Gw4D7Q|RUn+Gi_(d?aT4&$HsV)Ho^gA$a`e-I?_?}0t{YQ3W z@S>rSm+KXj9s&Y9ZzZQg`U=MsjG(GI;LbrP`?xUVvlV4`RA#r}00K_K#7{g9f!T53 ziNXEa6ADEH6XHPxnh_)KH)$uPn0mf-gxDt|5HndF##g%v Date: Fri, 15 Dec 2023 16:26:26 +0100 Subject: [PATCH 11/52] bugfix: prevent yikes in clone coldcard - creating backup with bypass_tmp=True on master secret --- releases/ChangeLog.md | 1 + shared/backups.py | 7 +++---- testing/data/ccbk-start.json | 1 + testing/test_backup.py | 20 +++++++++++++++++++- 4 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 testing/data/ccbk-start.json diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index a5e7e982..2fddb162 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -22,6 +22,7 @@ - Bugfix: Hide `Upgrade Firmware` menu item if temporary seed is active - Bugfix: Disallow using master seed as temporary seed - Bugfix: Do not allow to `APPLY` empty BIP-39 passphrase +- Bugfix: Fix yikes in `Clone Coldcard` (thanks to AnchorWatch) ## 5.2.0 - 2023-10-10 diff --git a/shared/backups.py b/shared/backups.py index 6fd033e8..11c2bff0 100644 --- a/shared/backups.py +++ b/shared/backups.py @@ -74,7 +74,7 @@ def render_backup_contents(bypass_tmp=False): for k,v in pairs: ADD(k, v) - if bypass_tmp: + if bypass_tmp and pa.tmp_value: current_tmp = pa.tmp_value[:] pa.tmp_value = None # we also need correct settings from main seed @@ -110,7 +110,7 @@ def render_backup_contents(bypass_tmp=False): rv.write('\n# EOF\n') - if bypass_tmp: + if bypass_tmp and current_tmp: # go back to tmp secret and its settings stash.SensitiveValues.clear_cache() pa.tmp_value = current_tmp @@ -281,7 +281,7 @@ async def make_complete_backup(fname_pattern='backup.7z', write_sflash=False): bypass_tmp = True elif pa.tmp_value: - if not await ux_confirm("An temporary seed is in effect, " + if not await ux_confirm("A temporary seed is in effect, " "so backup will be of that seed."): return @@ -593,7 +593,6 @@ file with an ephemeral public key will be written.''') try: with CardSlot() as card: fname, nice = card.pick_filename('ccbk-start.json', overwrite=True) - with card.open(fname, 'wb') as fd: fd.write(ujson.dumps(dict(pubkey=b2a_hex(my_pubkey)))) diff --git a/testing/data/ccbk-start.json b/testing/data/ccbk-start.json new file mode 100644 index 00000000..98908659 --- /dev/null +++ b/testing/data/ccbk-start.json @@ -0,0 +1 @@ +{"pubkey": "038e96756bb520bc3fece6c663c61db10cd8c971dfdbf757f1602fa7eed3f83689"} \ No newline at end of file diff --git a/testing/test_backup.py b/testing/test_backup.py index b7179efd..e5ddfe6c 100644 --- a/testing/test_backup.py +++ b/testing/test_backup.py @@ -1,4 +1,4 @@ -import pytest, time, json +import pytest, time, json, os, shutil from constants import simulator_fixed_words, simulator_fixed_tprv from pycoin.key.BIP32Node import BIP32Node from mnemonic import Mnemonic @@ -533,3 +533,21 @@ def test_seed_vault_backup_frozen(reset_seed_words, settings_set, repl): assert 'Coldcard backup file' in bk target = json.dumps(sv) assert target in bk + + +def test_clone_start(reset_seed_words, pick_menu_item, cap_story, goto_home): + sd_dir = "../unix/work/MicroSD" + num_7z = len([i for i in os.listdir(sd_dir) if i.endswith(".7z")]) + fname = "ccbk-start.json" + reset_seed_words() + shutil.copy(f"data/{fname}", sd_dir) + pick_menu_item("Advanced/Tools") + pick_menu_item("Backup") + pick_menu_item("Clone Coldcard") + time.sleep(1) + title, story = cap_story() + assert "Done" in story + assert "Take this MicroSD card back to other Coldcard" in story + goto_home() + assert len([i for i in os.listdir(sd_dir) if i.endswith(".7z")]) > num_7z + os.remove(f"{sd_dir}/{fname}") \ No newline at end of file From 9824e59ef9a99011b139ce0a235c3f831a3e1072 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Fri, 15 Dec 2023 08:51:27 +0100 Subject: [PATCH 12/52] fix change_pin test --- testing/test_change_pins.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/testing/test_change_pins.py b/testing/test_change_pins.py index 3c5ce0f3..be137330 100644 --- a/testing/test_change_pins.py +++ b/testing/test_change_pins.py @@ -107,7 +107,9 @@ def change_pin(cap_screen, cap_story, cap_menu, need_keypress, my_enter_pin): # use standard menus and UX to change a PIN title, story = cap_story() assert title == hdr_text - assert ('We strongly recommend' in story) or (CLR_PIN in story) + assert "changing the main PIN used to unlock your Coldcard" in story + assert "ABSOLUTELY NO WAY TO RECOVER A FORGOTTEN PIN!" in story + assert "Write it down" in story need_keypress('y') time.sleep(0.01) # required From 4359a9735be2a8fd1ca469535449aa56c83b265e Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Thu, 30 Nov 2023 18:04:18 +0100 Subject: [PATCH 13/52] Improve BIP39 Passphrase UX if temporary seed active and passphrase applicable --- releases/ChangeLog.md | 1 + shared/seed.py | 47 +++++++++++++++++++++-------------- shared/ux.py | 8 ++++-- testing/test_bip39pw.py | 54 +++++++++++++++++++++++++---------------- 4 files changed, 69 insertions(+), 41 deletions(-) diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index 2fddb162..64877855 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -17,6 +17,7 @@ - Enhancement: One instant retry on SE1 comm failures - Enhancement: Allow passphrase via USB if passphrase already set - operates on master seed. - Enhancement: Continuation of removal of obsolete Mk2/Mk3 code-paths from master branch. +- Enhancement: Improve BIP39 Passphrase UX when temporary seed is active and applicable. - Bugfix: Handle any failures in slot reading when loading settings - Bugfix: Add missing First Time UX for extended key import as master seed - Bugfix: Hide `Upgrade Firmware` menu item if temporary seed is active diff --git a/shared/seed.py b/shared/seed.py index d07941ac..4a29342e 100644 --- a/shared/seed.py +++ b/shared/seed.py @@ -1194,30 +1194,41 @@ class PassphraseMenu(MenuSystem): # empty string here - noop return - nv, xfp, parent_xfp = await calc_bip39_passphrase(pp_sofar, bypass_tmp=True) - - 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') - - msg1 = "" + nv, xfp, parent_xfp = await calc_bip39_passphrase(pp_sofar, + bypass_tmp=True) + parent_xfp_str = xfp2str(parent_xfp) + xfp_str = xfp2str(xfp) + msg0 = "master seed [%s]" % parent_xfp_str 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 temporary seed instead of the main seed.") + # we have ephemeral seed - can add passphrase to it as it is word based + t_nv, t_xfp, t_parent_xfp = await calc_bip39_passphrase(pp_sofar, + bypass_tmp=False) + t_parent_xfp_str = xfp2str(t_parent_xfp) + t_xfp_str = xfp2str(t_xfp) + choice_msg = "(1) master+pass:\n%s→%s\n\n" % (parent_xfp_str, xfp_str) + choice_msg += "(2) tmp+pass:\n%s→%s\n\n" % (t_parent_xfp_str, t_xfp_str) + ch = await ux_show_story(choice_msg, escape='12x', strict_escape=True, + scrollbar=False) + if ch == "x": return # exit + if ch == "2": + parent_xfp_str = t_parent_xfp_str + xfp = t_xfp + xfp_str = t_xfp_str + msg0 = "current active temporary seed [%s]" % t_parent_xfp_str + nv = t_nv - ch = await ux_show_story(msg + msg1, title="[%s]" % xfp2str(xfp), escape='12') + msg = ('Above is the master key fingerprint of the new wallet' + ' created by adding passphrase to %s.' + ' Press X to abort and keep editing passphrase,' + ' OK to use the new wallet, (1) to use' + ' and save to MicroSD') % msg0 + + ch = await ux_show_story(msg, title="[%s]" % xfp_str, escape='1') if ch == 'x': return - if ch == "2": - stash.SensitiveValues.clear_cache() - nv, xfp, parent_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, - meta="BIP-39 Passphrase on [%s]" % xfp2str(parent_xfp)) + meta="BIP-39 Passphrase on [%s]" % parent_xfp_str) if ch == '1': await PassphraseSaver().append(xfp, pp_sofar) diff --git a/shared/ux.py b/shared/ux.py index 70518038..ae3226f0 100644 --- a/shared/ux.py +++ b/shared/ux.py @@ -173,7 +173,8 @@ class PressRelease: # (using FontSmall) CH_PER_W = const(17) -async def ux_show_story(msg, title=None, escape=None, sensitive=False, strict_escape=False): +async def ux_show_story(msg, title=None, escape=None, sensitive=False, + strict_escape=False, scrollbar=True): # show a big long string, and wait for XY to continue # - returns character used to get out (X or Y) # - can accept other chars to 'escape' as well. @@ -239,7 +240,10 @@ async def ux_show_story(msg, title=None, escape=None, sensitive=False, strict_es y += 13 - dis.scroll_bar(top / len(lines)) + if scrollbar: + # help in cases when last char in a row hidden by scroll bar + dis.scroll_bar(top / len(lines)) + dis.show() # wait to do something diff --git a/testing/test_bip39pw.py b/testing/test_bip39pw.py index b0d268f0..cc8f8f5e 100644 --- a/testing/test_bip39pw.py +++ b/testing/test_bip39pw.py @@ -12,6 +12,7 @@ from ckcc_protocol.constants import * import json from mnemonic import Mnemonic from constants import simulator_fixed_xfp, simulator_fixed_words +from helpers import xfp2str # add the BIP39 test vectors vectors = json.load(open('bip39-vectors.json'))['english'] @@ -109,6 +110,7 @@ def set_bip39_pw(dev, need_keypress, reset_seed_words, cap_story, else: need_keypress("y") # do not store + time.sleep(.2) title, story = cap_story() assert "Above is the master key fingerprint" in story @@ -236,6 +238,8 @@ def test_bip39pass_on_ephemeral_seed(generate_ephemeral_words, import_ephemeral_ goto_eph_seed_menu() + sim_fp = xfp2str(simulator_fixed_xfp) + if stype == "words": # words sec = generate_ephemeral_words(24, from_main=True, seed_vault=seed_vault) @@ -258,31 +262,39 @@ def test_bip39pass_on_ephemeral_seed(generate_ephemeral_words, import_ephemeral_ enter_complex(passphrase) pick_menu_item("APPLY") time.sleep(.1) - title, story = cap_story() - # title is xfp = simulator fixed words + pass (as first iteration is always from main seed) - xfp0 = title[1:-1] - seed0 = Mnemonic.to_seed(simulator_fixed_words, passphrase=passphrase) - expect0 = BIP32Node.from_master_secret(seed0) - assert expect0.fingerprint().hex().upper() == xfp0 - assert "press (2) to add passphrase to the current active temporary seed" in story + title, choice_story = cap_story() + + tmp_seed = Mnemonic.to_seed(" ".join(sec), passphrase=passphrase) + tmp_node = BIP32Node.from_master_secret(tmp_seed) + tmp_fp = tmp_node.fingerprint().hex().upper() + + master_seed = Mnemonic.to_seed(simulator_fixed_words, passphrase=passphrase) + master_node = BIP32Node.from_master_secret(master_seed) + master_fp = master_node.fingerprint().hex().upper() + + choice_msg = "(1) master+pass:\n%s→%s\n\n" % (sim_fp, master_fp) + choice_msg += "(2) tmp+pass:\n%s→%s\n\n" % (parent_fp, tmp_fp) + + assert choice_story == choice_msg if on_eph: need_keypress("2") - time.sleep(.5) - title, story = cap_story() - xfp1 = title[1:-1] - seed1 = Mnemonic.to_seed(" ".join(sec), passphrase=passphrase) - expect1 = BIP32Node.from_master_secret(seed1) - assert expect1.fingerprint().hex().upper() == xfp1 - assert "press (2)" not in story - need_keypress("y") else: - need_keypress("y") + need_keypress("1") + time.sleep(.2) + title, story = cap_story() + title_xfp = title[1:-1] + + assert "created by adding passphrase to" in story if on_eph: - to_check = xfp1 + assert tmp_fp == title_xfp + assert f"current active temporary seed [{parent_fp}]" in story else: - to_check = xfp0 + assert master_fp == title_xfp + assert f"master seed [{sim_fp}]" in story + + need_keypress("y") time.sleep(.3) title, story = cap_story() @@ -292,7 +304,7 @@ def test_bip39pass_on_ephemeral_seed(generate_ephemeral_words, import_ephemeral_ time.sleep(.1) title, story = cap_story() assert "Saved to Seed Vault" in story - assert to_check in story + assert title_xfp in story need_keypress("y") else: @@ -303,7 +315,7 @@ def test_bip39pass_on_ephemeral_seed(generate_ephemeral_words, import_ephemeral_ pick_menu_item("Seed Vault") m = cap_menu() for i in m: - if to_check in i: + if title_xfp in i: pick_menu_item(i) break else: @@ -313,7 +325,7 @@ def test_bip39pass_on_ephemeral_seed(generate_ephemeral_words, import_ephemeral_ need_keypress("y") time.sleep(.1) _, story = cap_story() - assert to_check in story + assert title_xfp in story if on_eph: assert ("BIP-39 Passphrase on [%s]" % parent_fp) in story else: From 09b9065e10f1e1ff83c318bf91ad59434c5e721a Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Mon, 18 Dec 2023 09:58:51 -0500 Subject: [PATCH 14/52] edits, set target date --- releases/ChangeLog.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index 64877855..09be8f5f 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -1,28 +1,28 @@ -## 5.2.1 - 2023-11-XX +## 5.2.1 - 2023-12-19 -- New Feature: Temporary Seed from COLDCARD encrypted backup. -- New Feature: Export seed as SeedQR +- New Feature: Temporary Seed import from a COLDCARD encrypted backup. +- New Feature: Export seed words in SeedQR format (on screen QR). - New Feature: Provide user with info about transaction level timelocks ([nLockTime](https://en.bitcoin.it/wiki/NLockTime), [nSequence](https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki)) - when signing -- Enhancement: New submenu for saved BIP-39 Passphrases allowing to delete saved entries. + when signing. +- Enhancement: New submenu for saved BIP-39 Passphrases allowing delete of saved entries. - Enhancement: Add current temporary seed to Seed Vault from within Seed Vault menu. If current seed is temporary and not saved yet, `Add current tmp` menu item is shown in Seed Vault menu. -- Enhancement: Speed up opening `Passphrase` menu, when MicroSD card is available, by +- Enhancement: Speed up opening `Passphrase` menu when MicroSD card is available, by deferring card read (and decryption) until after `Restore Saved` menu item is selected. - Enhancement: `12 Words` menu option preferred on the top of the menu in all the seed menus (rather than 24 words). -- Enhancement: One instant retry on SE1 comm failures - Enhancement: Allow passphrase via USB if passphrase already set - operates on master seed. -- Enhancement: Continuation of removal of obsolete Mk2/Mk3 code-paths from master branch. - Enhancement: Improve BIP39 Passphrase UX when temporary seed is active and applicable. +- Enhancement: Continuation of removal of obsolete Mk2/Mk3 code-paths from master branch. +- Bugfix: One instant retry on SE1 commumication failures - Bugfix: Handle any failures in slot reading when loading settings -- Bugfix: Add missing First Time UX for extended key import as master seed -- Bugfix: Hide `Upgrade Firmware` menu item if temporary seed is active +- Bugfix: Add missing "First Time UX" for extended key import as master seed +- Bugfix: Hide `Upgrade Firmware` menu item if temporary seed is active (it cannot work) - Bugfix: Disallow using master seed as temporary seed -- Bugfix: Do not allow to `APPLY` empty BIP-39 passphrase +- Bugfix: Do not allow `APPLY` of empty BIP-39 passphrase - Bugfix: Fix yikes in `Clone Coldcard` (thanks to AnchorWatch) ## 5.2.0 - 2023-10-10 From 3977ae2ce0299d465e4541b7fc591ae85b7080ff Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Fri, 17 Nov 2023 14:39:22 +0100 Subject: [PATCH 15/52] HSM multisig 400 test --- testing/test_hsm.py | 124 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) diff --git a/testing/test_hsm.py b/testing/test_hsm.py index 8306b43f..7aab7a06 100644 --- a/testing/test_hsm.py +++ b/testing/test_hsm.py @@ -23,7 +23,7 @@ from onetimepass import get_hotp from objstruct import ObjectStruct as DICT from txn import render_address, fake_txn from psbt import ser_prop_key -from helpers import sign_msg, prandom +from helpers import sign_msg, prandom, xfp2str from ckcc_protocol.constants import * from ckcc_protocol.protocol import CCProtocolPacker from ckcc_protocol.protocol import CCUserRefused, CCProtoError @@ -776,6 +776,128 @@ def test_multiple_signings(dev, quick_start_hsm, is_simulator, attempt_psbt(psbt) +@pytest.mark.veryslow +@pytest.mark.parametrize("cc_first", [True, False]) +@pytest.mark.parametrize("M_N", [(2,3), (3,5), (15,15)]) +def test_multiple_signings_multisig(cc_first, M_N, dev, quick_start_hsm, + is_simulator, attempt_psbt, fake_txn, + load_hsm_users, auth_user, bitcoind, + request): + # signs 400 different PSBTs in loop beaing one leg of multisig + # CC must be on regtest if testing with real thing + af = "bech32" + M, N = M_N + bitcoind.delete_wallet_files(pattern="bitcoind--signer") + bitcoind.delete_wallet_files(pattern="watch_only_") + # create multiple bitcoin wallets (N-1) as one signer is CC + bitcoind_signers = [ + bitcoind.create_wallet(wallet_name=f"bitcoind--signer{i}", disable_private_keys=False, blank=False, + passphrase=None, avoid_reuse=False, descriptors=True) + for i in range(N - 1) + ] + for signer in bitcoind_signers: + signer.keypoolrefill(405) + # watch only wallet where multisig descriptor will be imported + bitcoind_watch_only = bitcoind.create_wallet( + wallet_name=f"watch_only_{af}_{M}of{N}", disable_private_keys=True, + blank=True, passphrase=None, avoid_reuse=False, descriptors=True + ) + # get keys from bitcoind signers + bitcoind_signers_xpubs = [] + for signer in bitcoind_signers: + target_desc = "" + bitcoind_descriptors = signer.listdescriptors()["descriptors"] + for desc in bitcoind_descriptors: + if desc["desc"].startswith("pkh(") and desc["internal"] is False: + target_desc = desc["desc"] + core_desc, checksum = target_desc.split("#") + # remove pkh(....) + core_key = core_desc[4:-1] + bitcoind_signers_xpubs.append(core_key) + + cc_deriv = "m/9999h/1h/0h" + cc_xpub = dev.send_recv(CCProtocolPacker.get_xpub(cc_deriv), timeout=None) + xfp_str = xfp2str(dev.master_fingerprint).lower() + cc_key_ext = f"[{xfp_str}/{cc_deriv.replace('m/','')}]{cc_xpub}/0/*" + cc_key_int = f"[{xfp_str}/{cc_deriv.replace('m/','')}]{cc_xpub}/1/*" + assert cc_xpub[1:4] == 'pub' + all_external = bitcoind_signers_xpubs + [cc_key_ext] + bitcoind_signers_xpubs_int = [i.replace("/0/*", "/1/*") for i in bitcoind_signers_xpubs] + all_internal = bitcoind_signers_xpubs_int + [cc_key_int] + template_ext = f"wsh(sortedmulti({M},{','.join(all_external)}))" + template_int = f"wsh(sortedmulti({M},{','.join(all_internal)}))" + desc_info_ext = bitcoind_watch_only.getdescriptorinfo(template_ext) + desc_info_int = bitcoind_watch_only.getdescriptorinfo(template_int) + desc_ext = desc_info_ext["descriptor"] # external with checksum + desc_int = desc_info_int["descriptor"] # internal with checksum + + desc_obj_ext = { + "desc": desc_ext, + "active": True, + "timestamp": "now", + "internal": False, + "range": [0, 405], + } + desc_obj_int = { + "desc": desc_int, + "active": True, + "timestamp": "now", + "internal": True, + "range": [0, 405], + } + # import multisig wallet to bitcoin core watch only wallet + res = bitcoind_watch_only.importdescriptors([desc_obj_int, desc_obj_ext]) + for obj in res: + assert obj["success"], obj + + # uploading only external to CC + file_len, sha = dev.upload_file(desc_ext.encode('ascii')) + open('debug/last-config.txt', 'wt').write(desc_ext) + dev.send_recv(CCProtocolPacker.multisig_enroll(file_len, sha), timeout=30000) + + time.sleep(.2) + if dev.is_simulator: + need_keypress = request.getfixturevalue('need_keypress') + need_keypress("y") + else: + import pdb;pdb.set_trace() # user interaction required on real CC + + multi_addr = bitcoind_watch_only.getnewaddress("", af) + # create spendable segwit utxo in multi wallet + bitcoind.supply_wallet.generatetoaddress(5, bitcoind.supply_wallet.getnewaddress()) + bitcoind.supply_wallet.sendtoaddress(address=multi_addr, amount=250) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + + policy = DICT(warnings_ok=True, must_log=1, rules=[dict(users=['pw'])]) + + load_hsm_users() + quick_start_hsm(policy) + + for count in range(400): + # do a peel chain + dest_a = bitcoind.supply_wallet.getnewaddress("", af) + psbt_resp = bitcoind_watch_only.walletcreatefundedpsbt( + [], [{dest_a: 0.3}], 0, {"fee_rate": 10, "change_type": af} + ) + psbt_str = psbt_resp.get("psbt") + if not cc_first: + signed = 0 + for signer in bitcoind_signers: + resp = signer.walletprocesspsbt(psbt_str, True) + psbt_str = resp.get("psbt") + signed +=1 + # do not want to finalize this + if signed == M - 1: + break + + psbt = base64.b64decode(psbt_str) + + auth_user.psbt_hash = sha256(psbt).digest() + auth_user("pw") + + attempt_psbt(psbt) + + def test_sign_msg_good(quick_start_hsm, change_hsm, attempt_msg_sign, addr_fmt=AF_CLASSIC): # message signing, but only at certain derivations permit = ['m/73', "m/*'", 'm/1p/3h/4/5/6/7' ] From 697b6e211dd48ae5d1c6515bcebef2a8fbef6dd7 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Sat, 16 Dec 2023 15:05:28 +0100 Subject: [PATCH 16/52] fix tests --- testing/helpers.py | 12 ++++++------ testing/test_backup.py | 5 +++-- testing/test_drv_entro.py | 5 ++++- testing/test_ephemeral.py | 6 +++--- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/testing/helpers.py b/testing/helpers.py index f3d5a95c..8bacb6ad 100644 --- a/testing/helpers.py +++ b/testing/helpers.py @@ -264,16 +264,16 @@ def seconds2human_readable(s): hours = s % (3600 * 24) // 3600 minutes = (s % 3600) // 60 seconds = (s % 3600) % 60 - msg = "" + msg = [] if days: - msg += "%dd" % days + msg.append("%dd" % days) if hours: - msg += " %dh" % hours + msg.append("%dh" % hours) if minutes: - msg += " %dm" % minutes + msg.append("%dm" % minutes) if seconds: - msg += " %ds" % seconds + msg.append("%ds" % seconds) - return msg + return " ".join(msg) # EOF diff --git a/testing/test_backup.py b/testing/test_backup.py index e5ddfe6c..8bf53aa6 100644 --- a/testing/test_backup.py +++ b/testing/test_backup.py @@ -49,7 +49,7 @@ def backup_system(settings_set, settings_remove, goto_home, pick_menu_item, 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 temporary seed is in effect" in body + assert "A temporary seed is in effect" in body assert "so backup will be of that seed" in body need_keypress("y") @@ -238,7 +238,7 @@ def test_backup_ephemeral_wallet(stype, pick_menu_item, need_keypress, goto_home pick_menu_item("Backup System") time.sleep(.1) title, story = cap_story() - assert "An temporary seed is in effect" in story + assert "A temporary seed is in effect" in story assert "so backup will be of that seed" in story need_keypress("y") time.sleep(.1) @@ -540,6 +540,7 @@ def test_clone_start(reset_seed_words, pick_menu_item, cap_story, goto_home): num_7z = len([i for i in os.listdir(sd_dir) if i.endswith(".7z")]) fname = "ccbk-start.json" reset_seed_words() + goto_home() shutil.copy(f"data/{fname}", sd_dir) pick_menu_item("Advanced/Tools") pick_menu_item("Backup") diff --git a/testing/test_drv_entro.py b/testing/test_drv_entro.py index e97799cf..222cbc6a 100644 --- a/testing/test_drv_entro.py +++ b/testing/test_drv_entro.py @@ -226,7 +226,8 @@ def test_bip_vectors(mode, index, entropy, expect, cap_story, need_keypress, ]) @pytest.mark.parametrize('index', [0, 1, 10, 100, 1000, 9999]) def test_path_index(mode, pattern, index, need_keypress, cap_screen_qr, - derive_bip85_secret): + derive_bip85_secret, reset_seed_words): + reset_seed_words() # Uses any key on Simulator; just checking for operation + entropy level _, story = derive_bip85_secret(mode, index) @@ -279,6 +280,8 @@ def test_path_index(mode, pattern, index, need_keypress, cap_screen_qr, elif 'WIF' in mode: assert qr == got + need_keypress("x") + def test_type_passwords(dev, cap_menu, pick_menu_item, goto_home, cap_story, need_keypress, cap_screen diff --git a/testing/test_ephemeral.py b/testing/test_ephemeral.py index 1ceccd59..058f23d0 100644 --- a/testing/test_ephemeral.py +++ b/testing/test_ephemeral.py @@ -119,11 +119,11 @@ def get_seed_value_ux(goto_home, pick_menu_item, need_keypress, cap_story, nfc_r if nfc: need_keypress("1") # show QR code - time.sleep(.1) + time.sleep(.2) need_keypress("3") # any QR can be exported via NFC - time.sleep(.1) + time.sleep(.2) str_words = nfc_read_text() - time.sleep(.1) + time.sleep(.5) need_keypress("y") # exit NFC animation return str_words.split(" ") # always truncated From 58f0adc56010e3592b78f6ab10b503a6a179ebcf Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Mon, 18 Dec 2023 10:08:32 -0500 Subject: [PATCH 17/52] logout at end of menu --- shared/flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/flow.py b/shared/flow.py index ba815bed..ea20a63c 100644 --- a/shared/flow.py +++ b/shared/flow.py @@ -331,9 +331,9 @@ NormalSystem = [ predicate=lambda: settings.get("emu", False) and has_secrets()), MenuItem('Seed Vault', menu=make_seed_vault_menu, predicate=lambda: settings.master_get('seedvault') and has_secrets()), - MenuItem('Secure Logout', f=logout_now), MenuItem('Advanced/Tools', menu=AdvancedNormalMenu), MenuItem('Settings', menu=SettingsMenu), + MenuItem('Secure Logout', f=logout_now), ] # Shown until unit is put into a numbered bag From d1c5b907c06e896bfd43174bdad9b5770898975b Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Mon, 18 Dec 2023 10:34:59 -0500 Subject: [PATCH 18/52] Remove FTUX, add simple welcome screen --- external/micropython | 2 +- releases/ChangeLog.md | 3 ++- shared/ftux.py | 59 +++++++++++++------------------------------ testing/conftest.py | 12 ++------- 4 files changed, 23 insertions(+), 53 deletions(-) diff --git a/external/micropython b/external/micropython index d680d41b..abf88c98 160000 --- a/external/micropython +++ b/external/micropython @@ -1 +1 @@ -Subproject commit d680d41bb547f6d81e09fa8ce6ddcea14ea97ee0 +Subproject commit abf88c98b6ee9897b6fcc8ffea0276f07447dd48 diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index 09be8f5f..57e4d554 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -17,12 +17,13 @@ - Enhancement: Allow passphrase via USB if passphrase already set - operates on master seed. - Enhancement: Improve BIP39 Passphrase UX when temporary seed is active and applicable. - Enhancement: Continuation of removal of obsolete Mk2/Mk3 code-paths from master branch. +- Bugfix: Confusing first-time UX replaced with simple welcome screen. - Bugfix: One instant retry on SE1 commumication failures - Bugfix: Handle any failures in slot reading when loading settings - Bugfix: Add missing "First Time UX" for extended key import as master seed - Bugfix: Hide `Upgrade Firmware` menu item if temporary seed is active (it cannot work) - Bugfix: Disallow using master seed as temporary seed -- Bugfix: Do not allow `APPLY` of empty BIP-39 passphrase +- Bugfix: Do not allow `APPLY` of empty BIP-39 passphrase. Use "Restore Master" instead. - Bugfix: Fix yikes in `Clone Coldcard` (thanks to AnchorWatch) ## 5.2.0 - 2023-10-10 diff --git a/shared/ftux.py b/shared/ftux.py index fc486990..860148cf 100644 --- a/shared/ftux.py +++ b/shared/ftux.py @@ -2,55 +2,32 @@ # # ftux.py - First Time User Experience! A new ride at the waterpark. # -import version +import version, ckcc from glob import settings -from ux import ux_show_story, the_ux, ux_dramatic_pause -from actions import change_nfc_enable, change_virtdisk_enable, change_usb_disable - -COMMON = '''\ -\n -You can change this later under Settings > Hardware On/Off.''' +from ux import ux_show_story, the_ux +from actions import change_usb_disable class FirstTimeUX: async def interact(self): # Help them enable the good stuff. # - they might have already enabled things - # - some features not on mk3 - if version.has_nfc and not settings.get('nfc', 0): - msg = '''Enable NFC/Tap?\n\n\ -Lets you Tap your mobile phone on the COLDCARD and \ -transfer data easily via NFC.''' + COMMON - ch = await ux_show_story(msg) - if ch == 'y': - settings.set('nfc', 1) - await change_nfc_enable(1) - await ux_dramatic_pause('Enabled.', 1) - - # Disabled for now, because limited audience and - # extra barrier to "just getting started" - if 0: # if not settings.get('vidsk', 0): - msg = '''Enable USB Drive?\n\n\ -Connect your COLDCARD directly as a USB flash drive \ -to your phone or desktop. You will be able to drag-n-drop or \ -save PSBT files like other drives/volumes.''' + COMMON - ch = await ux_show_story(msg) - if ch == 'y': - # put them into full-auto mode: 2 - settings.set('vidsk', 2) - await change_virtdisk_enable(2) - await ux_dramatic_pause('Enabled.', 1) - - if not settings.get('vidsk', 0) and not settings.get('du', 0): - msg = '''Disable USB port?\n\n\ -If you intend to operate in Air-Gap mode, where this COLDCARD \ -is never connected to anything but power, then this will disable the USB port.''' + COMMON - ch = await ux_show_story(msg) + await ux_show_story(''' +Your COLDCARD has been configured for \ +best security practises: - if ch == 'y': - settings.set('du', 1) - await change_usb_disable(1) - await ux_dramatic_pause('Disabled.', 1) +- USB disabled +- NFC disabled +- VDisk disabled + +You can change these under Settings > Hardware On/Off.''', title="Welcome!") + + if not ckcc.is_simulator(): + settings.set('du', 1) # disable USB + await change_usb_disable(1) + + #settings.set('nfc', 0) # default already + #settings.set('vidsk', 0) # same as default # done the_ux.pop() diff --git a/testing/conftest.py b/testing/conftest.py index 701c5ca0..2941c3b0 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -343,7 +343,7 @@ def cap_menu(sim_exec): @pytest.fixture(scope='module') def is_ftux_screen(sim_exec): - "are we presenting a view from ftux.py" + "are we presenting a view from ftux.py??" def doit(): rv = sim_exec('from ux import the_ux; RV.write(repr(' 'type(the_ux.top_of_stack())))') @@ -360,15 +360,7 @@ def expect_ftux(cap_menu, cap_story, need_keypress, is_ftux_screen): _, story = cap_story() if not story: break - # XXX test more here - if 'Enable NFC' in story: - need_keypress('x') - elif 'Enable USB' in story: - need_keypress('y') - elif 'Disable USB' in story: - need_keypress('x') - else: - raise ValueError(story) + need_keypress('y') m = cap_menu() assert m[0] == 'Ready To Sign' From f6f977503e81e7c2baceed8f3fc2618a1e9bf305 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Mon, 18 Dec 2023 10:42:48 -0500 Subject: [PATCH 19/52] cleanup of ftux --- shared/ftux.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/shared/ftux.py b/shared/ftux.py index 860148cf..57ffa53e 100644 --- a/shared/ftux.py +++ b/shared/ftux.py @@ -9,10 +9,18 @@ from actions import change_usb_disable class FirstTimeUX: async def interact(self): - # Help them enable the good stuff. - # - they might have already enabled things + # Force USB to be disabled by default, but also warn/tell user + # how to enable it, plus NFC and VirtDisk (already disabled by default) + if settings.get('du', None) is None: - await ux_show_story(''' + if not ckcc.is_simulator(): + settings.set('du', 1) # disable USB + await change_usb_disable(1) + + #settings.set('nfc', 0) # default already + #settings.set('vidsk', 0) # same as default + + await ux_show_story(''' Your COLDCARD has been configured for \ best security practises: @@ -22,14 +30,7 @@ best security practises: You can change these under Settings > Hardware On/Off.''', title="Welcome!") - if not ckcc.is_simulator(): - settings.set('du', 1) # disable USB - await change_usb_disable(1) - - #settings.set('nfc', 0) # default already - #settings.set('vidsk', 0) # same as default - - # done + # done, clear UX the_ux.pop() # EOF From 979c27387ecd63fe9d07e04557cbf0edb8ecf9c7 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Mon, 18 Dec 2023 11:34:30 -0500 Subject: [PATCH 20/52] Bump --- stm32/Makefile | 2 -- stm32/version.mk | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/stm32/Makefile b/stm32/Makefile index 6db97c86..94262543 100644 --- a/stm32/Makefile +++ b/stm32/Makefile @@ -16,10 +16,8 @@ clean clobber rc1: $(MAKE) -f MK4-Makefile $(MAKECMDGOALS) - $(MAKE) -f MK3-Makefile $(MAKECMDGOALS) release repro: $(MAKE) -f MK4-Makefile $(MAKECMDGOALS) - #NOTYET#$(MAKE) -f MK3-Makefile $(MAKECMDGOALS) # EOF diff --git a/stm32/version.mk b/stm32/version.mk index f367ad94..fa5e4718 100644 --- a/stm32/version.mk +++ b/stm32/version.mk @@ -1,4 +1,4 @@ # Our version for this release. -VERSION_STRING = 5.2.0 +VERSION_STRING = 5.2.1 From 5cb63b299d8d254398b1831c03827b11245ee6f5 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Mon, 18 Dec 2023 11:36:44 -0500 Subject: [PATCH 21/52] New release: 2023-12-18T1636-v5.2.1 --- releases/signatures.txt | 20 +++++++++++--------- stm32/COLDCARD_MK4/file_time.c | 6 +++--- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/releases/signatures.txt b/releases/signatures.txt index f30213be..035c1287 100644 --- a/releases/signatures.txt +++ b/releases/signatures.txt @@ -4,7 +4,9 @@ Hash: SHA256 715a3ec7a91d2366788b14a243e7343de875714b647d1e2bc906ecc5b752d8d9 README.md 8f71336a78573ccbd19b782e4f6e5930a8f944517884fe02d26eb0f38bf5c3ac Mk3ChangeLog.md a0c4d0ac3881a36704f0b620a13c72704531f656ee29368d5aac87dc5f21c7a1 History.md -b4255a8ed247ed8cc32c3b25d6380d6202bdb86357e5d6864abaac144ff95692 ChangeLog.md +9f91f9dba7dfd7663586e132181ffc6e2752da1c2ba146f15ba45c1eaa2420c0 ChangeLog.md +8b82811a7729eeaa0d9dd45fab4b74b37706a9fd6d045e6b06070a935d774613 2023-12-18T1636-v5.2.1-mk4-coldcard.dfu +d9bbb2ceeeec8c4279491f614a403001c3be1a5804bdd8ecd88d9fb2c2c9f50a 2023-12-18T1636-v5.2.1-mk4-coldcard-factory.dfu f4457dc44d08cbed9517e6260aa7163ecc254457276d3cdb0c2611af0f49ba9b 2023-10-26T1343-v6.2.1X-mk4-coldcard.dfu 1dcfb450f81883afe8f655239f06e238de7bae51e740cd4aa5ae6a0541772ad8 2023-10-26T1343-v6.2.1X-mk4-coldcard-factory.dfu 7fbed097d2757b21fde920f4b10f5f50d7e1aeca01ff52186dfde4883af5cace 2023-10-10T1735-v5.2.0-mk4-coldcard.dfu @@ -47,12 +49,12 @@ f05bc8dfed047c0c0abe5ed60621d2d14899b10717221c4af0942d96a1754f33 2021-03-29T192 bea27f263b524a66b3ed0a58c16805e98be0d7c3db20c2f7aab3238f2c6a6995 2019-12-19T1623-v3.0.6-coldcard.dfu -----BEGIN PGP SIGNATURE----- -iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmU6dq0ACgkQo6MbrVoq -WxDo1wf8D3PHmqMbwkhXOSX4brjOrcoklELXzgz5FNVCX18gwI+vOI8jW0nomZbc -5Huwe3veSvndhNKS0RoaRqC93EMqFXyq5ma/QMgp1JizCooHHA649FVeeXyfBAEp -r0iBaDgR2zsLAWR2wsCbOsiQvRygrBHpDHY3l2ivzvKCE3nm/SUuEixkIhb49Ba7 -LEblUm52xtst1ymQE0YwL2qin8OPTSGlJT9jldgk/FQgY+NE16y5fZ2hnSkAC0Zy -LKKVIY5zkkxjTb8kHnVqG5aMQdiKJDES1eQiJxobxjhgzPVQrjDI8kie3PRtMrDS -08NHbBQgqYgVlje9uKzfIDyhBNUtWQ== -=NsUm +iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmWAdRsACgkQo6MbrVoq +WxCIygf7B1x2Dnm9XbkgBpnF2jgUTOtvMTknnJENNT8SNjIYvXk8XW1HZLD2qR0Z +LYX+MTDv3n5kGPHqjgUeI5Uggws7GLRcJEpV6aEpM4aY+Ax9JO3XdqpKsshwvx3c +KdHus55qk0Gtto1W3QWTF46xNEMhltTnlvxpmR48GgD039RK/ApPB/2218xI9cSN +Nnc7pN4vwjkwIDLgHoxRmzNRm4v+zy36igcZADjhPXNwKPTEk0zjU3Ft8oIxwc4P +EEAxnSnChysemw827rjSmjDm5aIn9sRLmBG3GrcxKwB9eTKVGitf3Nen9QetiCf9 +fKJxqkTQpOJMrKySa3D4nqiHuZPwHA== +=kdPj -----END PGP SIGNATURE----- diff --git a/stm32/COLDCARD_MK4/file_time.c b/stm32/COLDCARD_MK4/file_time.c index a5e81cd0..c7ba501c 100644 --- a/stm32/COLDCARD_MK4/file_time.c +++ b/stm32/COLDCARD_MK4/file_time.c @@ -2,12 +2,12 @@ // // AUTO-generated. // -// built: 2023-10-10 -// version: 5.2.0 +// built: 2023-12-18 +// version: 5.2.1 // #include // this overrides ports/stm32/fatfs_port.c uint32_t get_fattime(void) { - return 0x574a2840UL; + return 0x57922840UL; } From 46dc0b5b6da04eec7293cc5a0a5d6f4df157da5b Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Mon, 18 Dec 2023 13:37:24 -0500 Subject: [PATCH 22/52] more british & precise --- shared/ftux.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/ftux.py b/shared/ftux.py index 57ffa53e..caaa5b1a 100644 --- a/shared/ftux.py +++ b/shared/ftux.py @@ -22,7 +22,7 @@ class FirstTimeUX: await ux_show_story(''' Your COLDCARD has been configured for \ -best security practises: +best security practices: - USB disabled - NFC disabled From 5bfdc4f45aebdd24f4a60d7b883f11321f2d86b4 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Mon, 18 Dec 2023 18:58:09 +0100 Subject: [PATCH 23/52] xprv master seed with tmp seeds and bip39 passphrase --- shared/auth.py | 12 ++++++-- shared/flow.py | 6 ++-- shared/nvstore.py | 2 +- shared/pwsave.py | 23 ++++++++++---- shared/seed.py | 58 ++++++++++++++++++++++++----------- shared/usb.py | 4 +-- testing/devtest/set_tprv.py | 25 ++++++++------- testing/test_bip39pw.py | 61 ++++++++++++++++++++++++++++++++++++- testing/test_ux.py | 5 +++ 9 files changed, 152 insertions(+), 44 deletions(-) diff --git a/shared/auth.py b/shared/auth.py index 37372a42..bb5c4989 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -1202,7 +1202,7 @@ class NewPassphrase(UserAuthorizedAction): title = "Passphrase" bypass_tmp = True - escape = "2" + escape = "x2" while 1: msg = ('BIP-39 passphrase (%d chars long) has been provided over ' 'USB connection. Should we switch to that wallet now?\n\n') @@ -1210,9 +1210,15 @@ class NewPassphrase(UserAuthorizedAction): escape += "1" msg += "Press (1) to add passphrase to currently active temporary seed. " + if settings.master_get("words", True): + escape += "y" + msg += "Press OK to add passphrase to master seed. " + msg += ('Press (2) to view the provided passphrase.\n\n' - 'OK to continue, X to cancel.') - ch = await ux_show_story(msg=msg % len(self._pw), title=title, escape=escape) + 'X to cancel.') + + ch = await ux_show_story(msg=msg % len(self._pw), title=title, + escape=escape, strict_escape=True) if ch == '2': await ux_show_story('Provided:\n\n%s\n\n' % self._pw, title=title) continue diff --git a/shared/flow.py b/shared/flow.py index ea20a63c..61059917 100644 --- a/shared/flow.py +++ b/shared/flow.py @@ -52,9 +52,9 @@ def se2_and_real_secret(): 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) + import stash + return settings.get('words', True) \ + or (settings.master_get('words', True) and stash.bip39_passphrase) HWTogglesMenu = [ diff --git a/shared/nvstore.py b/shared/nvstore.py index 2b18ab94..9fad86af 100644 --- a/shared/nvstore.py +++ b/shared/nvstore.py @@ -77,7 +77,7 @@ from version import mk_num, is_devmode KEEP_IF_BLANK_SETTINGS = ["bkpw", "wa", "sighshchk", "emu", "rz", "axskip", "del", "pms", "idle_to", "b39skip"] -SEEDVAULT_FIELDS = [ 'seeds', 'seedvault', 'xfp' ] +SEEDVAULT_FIELDS = ['seeds', 'seedvault', 'xfp', 'words'] NUM_SLOTS = const(100) SLOTS = range(NUM_SLOTS) diff --git a/shared/pwsave.py b/shared/pwsave.py index 05746845..c2159da6 100644 --- a/shared/pwsave.py +++ b/shared/pwsave.py @@ -115,13 +115,24 @@ class PassphraseSaverMenu(MenuSystem): bypass_tmp = True pw, expect_xfp = item.arg - if pa.tmp_value and settings.get("words", None): + if pa.tmp_value and settings.get("words", True): xfp = settings.get("xfp", 0) title = "[%s]" % xfp2str(xfp) - ch = await ux_show_story("Temporary seed is active. Press (1)" - " to add passphrase to the current active" - " temporary seed instead of the main seed.", - title=title, escape='1') + msg = ( + "Temporary seed is active. Press (1)" + " to add passphrase to the current active" + " temporary seed." + ) + escape = "1x" + if settings.master_get("words", True): + escape += "y" + msg += " Press OK to add to master seed." + + msg += "Press X to exit." + + ch = await ux_show_story(msg, title=title, escape=escape, + strict_escape=True) + if ch == "x": return if ch == '1': bypass_tmp = False @@ -130,7 +141,7 @@ class PassphraseSaverMenu(MenuSystem): if not applied: return - xfp = settings.get('xfp') + xfp = settings.get('xfp', 0) # verification step if xfp == expect_xfp: diff --git a/shared/seed.py b/shared/seed.py index 4a29342e..569cc50b 100644 --- a/shared/seed.py +++ b/shared/seed.py @@ -658,14 +658,14 @@ async def calc_bip39_passphrase(pw, bypass_tmp=False): # get xfp of parent reliably - cannot go to settings for this if in ephemeral if pa.tmp_value: with stash.SensitiveValues(bypass_tmp=bypass_tmp) as sv: - assert sv.mode == 'words' + assert sv.mode == 'words', sv.mode current_xfp = swab32(sv.node.my_fp()) else: current_xfp = settings.get("xfp", 0) 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' + assert sv.mode == 'words', sv.mode nv = SecretStash.encode(xprv=sv.node) xfp = swab32(sv.node.my_fp()) @@ -1194,34 +1194,58 @@ class PassphraseMenu(MenuSystem): # empty string here - noop return - nv, xfp, parent_xfp = await calc_bip39_passphrase(pp_sofar, - bypass_tmp=True) - parent_xfp_str = xfp2str(parent_xfp) - xfp_str = xfp2str(xfp) - msg0 = "master seed [%s]" % parent_xfp_str + mdata = None + tdata = None + + try: + m_nv, m_xfp, m_parent_xfp = await calc_bip39_passphrase(pp_sofar, + bypass_tmp=True) + m_parent_xfp_str = xfp2str(m_parent_xfp) + m_xfp_str = xfp2str(m_xfp) + mdata = ( + m_nv, m_xfp, m_xfp_str, m_parent_xfp_str, + "master seed [%s]" % m_parent_xfp_str, + "(1) master+pass:\n%s→%s\n\n" % (m_parent_xfp_str, m_xfp_str), + ) + except AssertionError: pass + if pa.tmp_value and settings.get("words", True): # we have ephemeral seed - can add passphrase to it as it is word based t_nv, t_xfp, t_parent_xfp = await calc_bip39_passphrase(pp_sofar, bypass_tmp=False) t_parent_xfp_str = xfp2str(t_parent_xfp) t_xfp_str = xfp2str(t_xfp) - choice_msg = "(1) master+pass:\n%s→%s\n\n" % (parent_xfp_str, xfp_str) - choice_msg += "(2) tmp+pass:\n%s→%s\n\n" % (t_parent_xfp_str, t_xfp_str) - ch = await ux_show_story(choice_msg, escape='12x', strict_escape=True, - scrollbar=False) + tdata = ( + t_nv, t_xfp, t_xfp_str, t_parent_xfp_str, + "current active temporary seed [%s]" % t_parent_xfp_str, + "(2) tmp+pass:\n%s→%s\n\n" % (t_parent_xfp_str, t_xfp_str), + ) + + if tdata is None and mdata is None: + # if master is not word based, temporary has to be, otherwise "Passphrase" + # not offered in menu + # should never be seen by user because flow.py::bip39_passphrase_active + await ux_show_story(title="FAILED", msg="Need word based secret") + return + + tmp = False + if tdata and mdata: + ch = await ux_show_story(mdata[-1] + tdata[-1], escape='12x', + strict_escape=True, scrollbar=False) if ch == "x": return # exit if ch == "2": - parent_xfp_str = t_parent_xfp_str - xfp = t_xfp - xfp_str = t_xfp_str - msg0 = "current active temporary seed [%s]" % t_parent_xfp_str - nv = t_nv + tmp = True + elif tdata: + tmp = True + + data = tdata if tmp else mdata + nv, xfp, xfp_str, parent_xfp_str, msg, _ = data msg = ('Above is the master key fingerprint of the new wallet' ' created by adding passphrase to %s.' ' Press X to abort and keep editing passphrase,' ' OK to use the new wallet, (1) to use' - ' and save to MicroSD') % msg0 + ' and save to MicroSD') % msg ch = await ux_show_story(msg, title="[%s]" % xfp_str, escape='1') if ch == 'x': diff --git a/shared/usb.py b/shared/usb.py index 0a4d5d30..4715e0f9 100644 --- a/shared/usb.py +++ b/shared/usb.py @@ -546,11 +546,11 @@ class USBHandler: if cmd == 'pass': # bip39 passphrase provided, maybe use it if authorized assert self.encrypted_req, 'must encrypt' - import stash + from flow import bip39_passphrase_active from auth import start_bip39_passphrase from glob import settings - assert settings.get('words', True) or stash.bip39_passphrase, 'no seed' + assert bip39_passphrase_active(), 'no seed' assert len(args) < 400, 'too long' pw = str(args, 'utf8') assert len(pw) < 100, 'too long' diff --git a/testing/devtest/set_tprv.py b/testing/devtest/set_tprv.py index 4c8f637f..f0d7cbae 100644 --- a/testing/devtest/set_tprv.py +++ b/testing/devtest/set_tprv.py @@ -7,6 +7,7 @@ import stash, chains from h import b2a_hex from pincodes import pa from glob import settings +from nvstore import SettingsObject from stash import SecretStash, SensitiveValues from utils import xfp2str, swab32 @@ -20,19 +21,21 @@ v = node.deserialize(main.TPRV) assert v == b32_version_priv assert node -if settings.get('xfp') == swab32(node.my_fp()): - print("right xfp already") +settings.current = sim_defaults +settings.set('chain', 'XTN') +settings.set('words', False) -else: - settings.current = sim_defaults - settings.set('chain', 'XTN') +pa.tmp_value = None +SettingsObject.master_sv_data = {} +SettingsObject.master_nvram_key = None - raw = SecretStash.encode(xprv=node) - pa.change(new_secret=raw) - pa.new_main_secret(raw) +raw = SecretStash.encode(xprv=node) +pa.change(new_secret=raw) +pa.new_main_secret(raw) +settings.set('words', False) - print("New key in effect: %s" % settings.get('xpub', 'MISSING')) - print("Fingerprint: %s" % xfp2str(settings.get('xfp', 0))) +print("New key in effect: %s" % settings.get('xpub', 'MISSING')) +print("Fingerprint: %s" % xfp2str(settings.get('xfp', 0))) - assert settings.get('xfp', 0) == swab32(node.my_fp()) +assert settings.get('xfp', 0) == swab32(node.my_fp()) diff --git a/testing/test_bip39pw.py b/testing/test_bip39pw.py index cc8f8f5e..a4158464 100644 --- a/testing/test_bip39pw.py +++ b/testing/test_bip39pw.py @@ -11,7 +11,7 @@ from ckcc_protocol.protocol import CCProtocolPacker, CCProtoError, CCUserRefused from ckcc_protocol.constants import * import json from mnemonic import Mnemonic -from constants import simulator_fixed_xfp, simulator_fixed_words +from constants import simulator_fixed_xfp, simulator_fixed_words, simulator_fixed_tprv from helpers import xfp2str # add the BIP39 test vectors @@ -371,4 +371,63 @@ def test_bip39pass_on_ephemeral_seed_usb(generate_ephemeral_words, import_epheme assert xpub in ident_story +@pytest.mark.parametrize("usb", [True, False]) +def test_tmp_on_xprv_master(generate_ephemeral_words, goto_home, cap_menu, + pick_menu_item, need_keypress, enter_complex, + cap_story, unit_test, microsd_path, expect_ftux, + set_bip39_pw, usb): + passphrase = "jfkdsfdks" + fname = "ek.txt" + fpath = microsd_path("ek.txt") + with open(fpath, "w") as f: + f.write(simulator_fixed_tprv) + unit_test('devtest/clear_seed.py') + time.sleep(.2) + pick_menu_item('Import Existing') + pick_menu_item("Import XPRV") + time.sleep(.2) + title, story = cap_story() + if "Press (1)" in story: + need_keypress("1") + + need_keypress("y") # Select file containing... + pick_menu_item(fname) + time.sleep(.2) + expect_ftux() + m = cap_menu() + assert "Passphrase" not in m + sec = generate_ephemeral_words(24, from_main=True, seed_vault=False) + parent = Mnemonic.to_seed(" ".join(sec), passphrase=passphrase) + parent_node = BIP32Node.from_master_secret(parent, netcode="XTN") + parent_fp = parent_node.fingerprint().hex().upper() + m = cap_menu() + # temporary seed is word-based - offer passphrase + assert "Passphrase" in m + if usb: + res_fp = set_bip39_pw(passphrase, reset=False, seed_vault=False, on_tmp=True) + assert xfp2str(res_fp) == parent_fp + with pytest.raises(Exception): + set_bip39_pw(passphrase, reset=False, seed_vault=False, on_tmp=True) + + return + + pick_menu_item("Passphrase") + need_keypress("y") + enter_complex(passphrase) + pick_menu_item("APPLY") + time.sleep(.1) + title, story = cap_story() + + + assert parent_fp in title # no choice story + assert "current active temporary seed" in story + need_keypress("y") + time.sleep(.2) + title, story = cap_story() + if "Press (1)" in story: + need_keypress("y") + + m = cap_menu() + assert "Passphrase" not in m + # EOF diff --git a/testing/test_ux.py b/testing/test_ux.py index 89bc2177..759f059d 100644 --- a/testing/test_ux.py +++ b/testing/test_ux.py @@ -738,6 +738,10 @@ def test_menu_wrapping(goto_home, pick_menu_item, cap_story, need_keypress, cap_ # home for i in range(10): # settings on 5th in home (10 is way past that) need_keypress(DOWN) + + # sitting at Logout + # one up to get to settings + need_keypress(UP) need_keypress("y") menu = cap_menu() # assert we are in settings, meaning we found bottom of home menu @@ -745,6 +749,7 @@ def test_menu_wrapping(goto_home, pick_menu_item, cap_story, need_keypress, cap_ for i in range(10): need_keypress(UP) + need_keypress("y") menu = cap_menu() # assert we are in Login settings, meaning we found top of settings menu From 84a6a44bbe7c4edde3c1070b086e90a07b2c7c9f Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Tue, 19 Dec 2023 09:44:43 -0500 Subject: [PATCH 24/52] New release: 2023-12-19T1444-v5.2.1 --- releases/signatures.txt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/releases/signatures.txt b/releases/signatures.txt index 035c1287..7ad1857a 100644 --- a/releases/signatures.txt +++ b/releases/signatures.txt @@ -5,6 +5,8 @@ Hash: SHA256 8f71336a78573ccbd19b782e4f6e5930a8f944517884fe02d26eb0f38bf5c3ac Mk3ChangeLog.md a0c4d0ac3881a36704f0b620a13c72704531f656ee29368d5aac87dc5f21c7a1 History.md 9f91f9dba7dfd7663586e132181ffc6e2752da1c2ba146f15ba45c1eaa2420c0 ChangeLog.md +06d1048bea43c5d7c72c5e5f395a676620ce884aed0cd152627a86d922e2f3ab 2023-12-19T1444-v5.2.1-mk4-coldcard.dfu +3eb9c4b1add88a6fe412d783b8f4b895241a67e423bbacc6a13816a5216a30fe 2023-12-19T1444-v5.2.1-mk4-coldcard-factory.dfu 8b82811a7729eeaa0d9dd45fab4b74b37706a9fd6d045e6b06070a935d774613 2023-12-18T1636-v5.2.1-mk4-coldcard.dfu d9bbb2ceeeec8c4279491f614a403001c3be1a5804bdd8ecd88d9fb2c2c9f50a 2023-12-18T1636-v5.2.1-mk4-coldcard-factory.dfu f4457dc44d08cbed9517e6260aa7163ecc254457276d3cdb0c2611af0f49ba9b 2023-10-26T1343-v6.2.1X-mk4-coldcard.dfu @@ -49,12 +51,12 @@ f05bc8dfed047c0c0abe5ed60621d2d14899b10717221c4af0942d96a1754f33 2021-03-29T192 bea27f263b524a66b3ed0a58c16805e98be0d7c3db20c2f7aab3238f2c6a6995 2019-12-19T1623-v3.0.6-coldcard.dfu -----BEGIN PGP SIGNATURE----- -iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmWAdRsACgkQo6MbrVoq -WxCIygf7B1x2Dnm9XbkgBpnF2jgUTOtvMTknnJENNT8SNjIYvXk8XW1HZLD2qR0Z -LYX+MTDv3n5kGPHqjgUeI5Uggws7GLRcJEpV6aEpM4aY+Ax9JO3XdqpKsshwvx3c -KdHus55qk0Gtto1W3QWTF46xNEMhltTnlvxpmR48GgD039RK/ApPB/2218xI9cSN -Nnc7pN4vwjkwIDLgHoxRmzNRm4v+zy36igcZADjhPXNwKPTEk0zjU3Ft8oIxwc4P -EEAxnSnChysemw827rjSmjDm5aIn9sRLmBG3GrcxKwB9eTKVGitf3Nen9QetiCf9 -fKJxqkTQpOJMrKySa3D4nqiHuZPwHA== -=kdPj +iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmWBrFoACgkQo6MbrVoq +WxAwaAf/UKEruxg3f9CC5/N3ASKKfR7MfL3b37C9nKPuNCDwFq+bdyxJFB95/VIj +wYL3a0/GV4lQI2q9GDPDJ8DFb2AF2HaX3VwnqV6o4Pe4T/WcFfZsQeOlPAlF9lfX +joLlQ4llbre7dJhdqwwOJHmHqn8SW1Jf89f1Z9ArtfDTNLAvW7BacWjZrgtrOpZN +weNAkcAgieQPI4PWO074RBXe9t70whtDEI+fmObXnyHM3xKEq1NvRyIS6amMk2+I +LKHNKWBJCpG5K6AUxjkXR99KEZd7GNU6KEa71HdOhrjO+NRbkTbdQJ9w2UlVtUvk +JM2e1Tuxo9XMlRhkCqsXxcqgypWg7g== +=MqT/ -----END PGP SIGNATURE----- From 7cea7ef54960fccca17d35d1cef742c6e3a43a9f Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Tue, 19 Dec 2023 09:51:23 -0500 Subject: [PATCH 25/52] Remove junk --- releases/signatures.txt | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/releases/signatures.txt b/releases/signatures.txt index 7ad1857a..884e7ad8 100644 --- a/releases/signatures.txt +++ b/releases/signatures.txt @@ -7,8 +7,6 @@ a0c4d0ac3881a36704f0b620a13c72704531f656ee29368d5aac87dc5f21c7a1 History.md 9f91f9dba7dfd7663586e132181ffc6e2752da1c2ba146f15ba45c1eaa2420c0 ChangeLog.md 06d1048bea43c5d7c72c5e5f395a676620ce884aed0cd152627a86d922e2f3ab 2023-12-19T1444-v5.2.1-mk4-coldcard.dfu 3eb9c4b1add88a6fe412d783b8f4b895241a67e423bbacc6a13816a5216a30fe 2023-12-19T1444-v5.2.1-mk4-coldcard-factory.dfu -8b82811a7729eeaa0d9dd45fab4b74b37706a9fd6d045e6b06070a935d774613 2023-12-18T1636-v5.2.1-mk4-coldcard.dfu -d9bbb2ceeeec8c4279491f614a403001c3be1a5804bdd8ecd88d9fb2c2c9f50a 2023-12-18T1636-v5.2.1-mk4-coldcard-factory.dfu f4457dc44d08cbed9517e6260aa7163ecc254457276d3cdb0c2611af0f49ba9b 2023-10-26T1343-v6.2.1X-mk4-coldcard.dfu 1dcfb450f81883afe8f655239f06e238de7bae51e740cd4aa5ae6a0541772ad8 2023-10-26T1343-v6.2.1X-mk4-coldcard-factory.dfu 7fbed097d2757b21fde920f4b10f5f50d7e1aeca01ff52186dfde4883af5cace 2023-10-10T1735-v5.2.0-mk4-coldcard.dfu @@ -51,12 +49,12 @@ f05bc8dfed047c0c0abe5ed60621d2d14899b10717221c4af0942d96a1754f33 2021-03-29T192 bea27f263b524a66b3ed0a58c16805e98be0d7c3db20c2f7aab3238f2c6a6995 2019-12-19T1623-v3.0.6-coldcard.dfu -----BEGIN PGP SIGNATURE----- -iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmWBrFoACgkQo6MbrVoq -WxAwaAf/UKEruxg3f9CC5/N3ASKKfR7MfL3b37C9nKPuNCDwFq+bdyxJFB95/VIj -wYL3a0/GV4lQI2q9GDPDJ8DFb2AF2HaX3VwnqV6o4Pe4T/WcFfZsQeOlPAlF9lfX -joLlQ4llbre7dJhdqwwOJHmHqn8SW1Jf89f1Z9ArtfDTNLAvW7BacWjZrgtrOpZN -weNAkcAgieQPI4PWO074RBXe9t70whtDEI+fmObXnyHM3xKEq1NvRyIS6amMk2+I -LKHNKWBJCpG5K6AUxjkXR99KEZd7GNU6KEa71HdOhrjO+NRbkTbdQJ9w2UlVtUvk -JM2e1Tuxo9XMlRhkCqsXxcqgypWg7g== -=MqT/ +iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmWBreIACgkQo6MbrVoq +WxDUZAf+MsAGxJZiyMpkGy3Vv025YH52VMOe6tSRdPe5Pu6016EohMgI25u5qcT5 +bvrh3LNctTh5JdkFscxAayWS+a+n2zj71OFogsmkxUN6AvVfRlcSt0x3nqXb4g5j +QB7FW00cWlD8x1oX665hxSwuTJpS2CIdJSbdcf1OxWoYR4wIToHM1sGj/E3bcfih +amqmcvmPORLOC9jCIax2lhghiRV/upoJW+G9HPzgviXouh64ds+usLkq/OoGpY2Y +XXKukqolVxWEy8KCz1u9XAKOkvW+3dq/MtzUhgWPKhwalFxS6SIbSVGNr/eZK+TP +4qVQ4pDW5eMruyH3KYmqwfIU54L62w== +=jOoG -----END PGP SIGNATURE----- From f6aec0ec2a1aa6ed59ab6af3b119aebc0affca43 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Tue, 19 Dec 2023 10:47:52 -0500 Subject: [PATCH 26/52] spelling --- releases/ChangeLog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index 57e4d554..ea5f51a8 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -18,7 +18,7 @@ - Enhancement: Improve BIP39 Passphrase UX when temporary seed is active and applicable. - Enhancement: Continuation of removal of obsolete Mk2/Mk3 code-paths from master branch. - Bugfix: Confusing first-time UX replaced with simple welcome screen. -- Bugfix: One instant retry on SE1 commumication failures +- Bugfix: One instant retry on SE1 communication failures - Bugfix: Handle any failures in slot reading when loading settings - Bugfix: Add missing "First Time UX" for extended key import as master seed - Bugfix: Hide `Upgrade Firmware` menu item if temporary seed is active (it cannot work) From d289bfc7c2c455b3d568920e8a47a5e5cebe6f20 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Tue, 19 Dec 2023 18:49:21 +0100 Subject: [PATCH 27/52] bugfix: lockdown temporary seed was no-op --- releases/ChangeLog.md | 4 ++++ shared/actions.py | 17 +++++++++-------- shared/pincodes.py | 3 ++- shared/seed.py | 27 +++++++++++++++++++-------- 4 files changed, 34 insertions(+), 17 deletions(-) diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index ea5f51a8..6e5afa61 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -1,3 +1,7 @@ +## 5.2.2 - 2023-12-21 + +- Bugfix: Re-enable `Lock Down Seed` which was disabled by accident + ## 5.2.1 - 2023-12-19 - New Feature: Temporary Seed import from a COLDCARD encrypted backup. diff --git a/shared/actions.py b/shared/actions.py index 9f13a474..4b420dad 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -566,25 +566,26 @@ async def convert_ephemeral_to_master(*a): return words = settings.get("words", True) - msg = 'Convert currently used ' - msg += 'BIP-39 passphrase ' if bip39_passphrase else 'temporary seed ' - msg += 'to main seed. ' + _type = 'BIP-39 passphrase ' if bip39_passphrase else 'temporary seed ' + msg = 'Convert currently used %s to master seed. Old master seed' % _type if words or bip39_passphrase: - msg += 'Main seed words themselves are erased forever, ' + msg += ' words themselves are erased forever, ' else: - msg += 'Main seed is erased forever, ' + msg += ' is erased forever, ' - msg += 'but effectively there is no other change. ' + msg += ('and its settings blanked. This action is destructive ' + 'and may affect funds, if any, on old master seed. ') if bip39_passphrase: - msg += ('BIP-39 passphrase is currently in effect, its value ' + msg += ('BIP-39 passphrase ' '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.' + msg += 'A reboot is part of this process. ' + msg += ('PIN code, and %s funds are not affected.' % _type) if not await ux_confirm(msg): return await ux_aborted() diff --git a/shared/pincodes.py b/shared/pincodes.py index 4ebad4ef..15797d3d 100644 --- a/shared/pincodes.py +++ b/shared/pincodes.py @@ -366,7 +366,8 @@ class PinAttempt: def change(self, **kws): # change various values, stored in secure element - if self.tmp_value: return + if not kws.pop("tmp_lockdown", False): + if self.tmp_value: return self.roundtrip(3, **kws) diff --git a/shared/seed.py b/shared/seed.py index 569cc50b..c59c4a84 100644 --- a/shared/seed.py +++ b/shared/seed.py @@ -682,18 +682,29 @@ async def set_bip39_passphrase(pw, bypass_tmp=False, summarize_ux=True): async def remember_ephemeral_seed(): # Compute current xprv and switch to using that as root secret. import stash - from glob import dis + from nvstore import SettingsObject + from glob import dis, settings - dis.fullscreen('Check...') - with stash.SensitiveValues() as sv: - nv = sv.encoded_secret() + # we are already at temporary seed, with correct + # settings in use - no need to call new_main_secret + # at the end + + # locking down temporary as new master + # old master settings are destroyed + dis.fullscreen("Cleanup...") + assert pa.tmp_value, "no tmp" + assert settings.master_nvram_key, "master nvram k" + old_master = SettingsObject(settings.master_nvram_key) + old_master.load() + old_master.blank() + del old_master dis.fullscreen('Saving...') - pa.change(new_secret=nv) + pa.change(new_secret=pa.tmp_value, tmp_lockdown=True) - # re-read settings since key is now different - # - also captures xfp, xpub at this point - pa.new_main_secret(nv) + # not needed - will be handled by reboot + settings.master_nvram_key = None + settings.master_sv_data = {} # check and reload secret pa.reset() From 55d549085267cf5c46496251a8aab190f6397128 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Thu, 21 Dec 2023 10:02:46 -0500 Subject: [PATCH 28/52] nits --- releases/ChangeLog.md | 2 +- shared/actions.py | 4 ++-- stm32/version.mk | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index 6e5afa61..077f8e88 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -1,6 +1,6 @@ ## 5.2.2 - 2023-12-21 -- Bugfix: Re-enable `Lock Down Seed` which was disabled by accident +- Bugfix: Re-enable `Lock Down Seed` feature which was disabled by accident ## 5.2.1 - 2023-12-19 diff --git a/shared/actions.py b/shared/actions.py index 4b420dad..f7803297 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -566,7 +566,7 @@ async def convert_ephemeral_to_master(*a): return words = settings.get("words", True) - _type = 'BIP-39 passphrase ' if bip39_passphrase else 'temporary seed ' + _type = 'BIP-39 passphrase' if bip39_passphrase else 'temporary seed' msg = 'Convert currently used %s to master seed. Old master seed' % _type if words or bip39_passphrase: msg += ' words themselves are erased forever, ' @@ -585,7 +585,7 @@ async def convert_ephemeral_to_master(*a): msg += 'The resulting wallet cannot be used with any other passphrase. ' msg += 'A reboot is part of this process. ' - msg += ('PIN code, and %s funds are not affected.' % _type) + msg += 'PIN code, and %s funds are not affected.' % _type if not await ux_confirm(msg): return await ux_aborted() diff --git a/stm32/version.mk b/stm32/version.mk index fa5e4718..9aa9dd27 100644 --- a/stm32/version.mk +++ b/stm32/version.mk @@ -1,4 +1,4 @@ # Our version for this release. -VERSION_STRING = 5.2.1 +VERSION_STRING = 5.2.2 From f991f4125ed66d57c91a4105c49f437eea6f5e40 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Thu, 21 Dec 2023 10:26:33 -0500 Subject: [PATCH 29/52] New release: 2023-12-21T1526-v5.2.2 --- releases/signatures.txt | 20 +++++++++++--------- stm32/COLDCARD_MK4/file_time.c | 6 +++--- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/releases/signatures.txt b/releases/signatures.txt index 884e7ad8..8d598adf 100644 --- a/releases/signatures.txt +++ b/releases/signatures.txt @@ -4,7 +4,9 @@ Hash: SHA256 715a3ec7a91d2366788b14a243e7343de875714b647d1e2bc906ecc5b752d8d9 README.md 8f71336a78573ccbd19b782e4f6e5930a8f944517884fe02d26eb0f38bf5c3ac Mk3ChangeLog.md a0c4d0ac3881a36704f0b620a13c72704531f656ee29368d5aac87dc5f21c7a1 History.md -9f91f9dba7dfd7663586e132181ffc6e2752da1c2ba146f15ba45c1eaa2420c0 ChangeLog.md +e70015cb0964b1b3ec7a77d119f9bb22dbbf56324671b6dc26122af16a7a7060 ChangeLog.md +4651fb81dc04ac07ae53535f4246ef7f32611c50853de9edaefa68f3c64e1fac 2023-12-21T1526-v5.2.2-mk4-coldcard.dfu +a49cd00808732c67b359c9f86814ddeafc63a1040823b6c1d2035a870575c9ed 2023-12-21T1526-v5.2.2-mk4-coldcard-factory.dfu 06d1048bea43c5d7c72c5e5f395a676620ce884aed0cd152627a86d922e2f3ab 2023-12-19T1444-v5.2.1-mk4-coldcard.dfu 3eb9c4b1add88a6fe412d783b8f4b895241a67e423bbacc6a13816a5216a30fe 2023-12-19T1444-v5.2.1-mk4-coldcard-factory.dfu f4457dc44d08cbed9517e6260aa7163ecc254457276d3cdb0c2611af0f49ba9b 2023-10-26T1343-v6.2.1X-mk4-coldcard.dfu @@ -49,12 +51,12 @@ f05bc8dfed047c0c0abe5ed60621d2d14899b10717221c4af0942d96a1754f33 2021-03-29T192 bea27f263b524a66b3ed0a58c16805e98be0d7c3db20c2f7aab3238f2c6a6995 2019-12-19T1623-v3.0.6-coldcard.dfu -----BEGIN PGP SIGNATURE----- -iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmWBreIACgkQo6MbrVoq -WxDUZAf+MsAGxJZiyMpkGy3Vv025YH52VMOe6tSRdPe5Pu6016EohMgI25u5qcT5 -bvrh3LNctTh5JdkFscxAayWS+a+n2zj71OFogsmkxUN6AvVfRlcSt0x3nqXb4g5j -QB7FW00cWlD8x1oX665hxSwuTJpS2CIdJSbdcf1OxWoYR4wIToHM1sGj/E3bcfih -amqmcvmPORLOC9jCIax2lhghiRV/upoJW+G9HPzgviXouh64ds+usLkq/OoGpY2Y -XXKukqolVxWEy8KCz1u9XAKOkvW+3dq/MtzUhgWPKhwalFxS6SIbSVGNr/eZK+TP -4qVQ4pDW5eMruyH3KYmqwfIU54L62w== -=jOoG +iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmWEWSgACgkQo6MbrVoq +WxClQAf/drgk06wrmERHRf5Q55NgISA4zBlm0yCSzaSdkCvKFB7yql8B1YBxH+5S +4/dFIrqZABV86IU1fVhd7nbZEi2AgfdpWPrzLHZAwsvBeqApAr757SzBLDDQ2r0W +aqwX82qzNTRIkaYtE9qDuaR0fCOysKMR8SwpdkjLOrzXo1EQZgYng6mW899hbBQo +Ekxq0tGIV+j1VnyTvy0HQ2Ip2d3Nf/1vr74xfVXQ204JsitWd2UgN0zZoXz2Nt4N +8G8ZuCQvgj99wKtc6H240DYC7oo+/b9QyapbQNWjhrPmq70MvStkU/1eKEFoy+WY +hfyM6Nb6PNkLjxd2lN/Bu4pVHvBAKg== +=mhUl -----END PGP SIGNATURE----- diff --git a/stm32/COLDCARD_MK4/file_time.c b/stm32/COLDCARD_MK4/file_time.c index c7ba501c..486f3b4b 100644 --- a/stm32/COLDCARD_MK4/file_time.c +++ b/stm32/COLDCARD_MK4/file_time.c @@ -2,12 +2,12 @@ // // AUTO-generated. // -// built: 2023-12-18 -// version: 5.2.1 +// built: 2023-12-21 +// version: 5.2.2 // #include // this overrides ports/stm32/fatfs_port.c uint32_t get_fattime(void) { - return 0x57922840UL; + return 0x57952840UL; } From f755947f7eaa37f203b1058259920a5da6fcc968 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Tue, 2 Jan 2024 13:17:45 +0100 Subject: [PATCH 30/52] bugfix: fix pwsave froze --- releases/ChangeLog.md | 4 ++ shared/pwsave.py | 86 +++++++++++++++++++++---------------------- 2 files changed, 46 insertions(+), 44 deletions(-) diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index 077f8e88..a6260622 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -1,3 +1,7 @@ +## 5.2.3 - 2024-XX-XX + +- Bugfix: Saving passphrase on SD Card caused a freeze that required reboot + ## 5.2.2 - 2023-12-21 - Bugfix: Re-enable `Lock Down Seed` feature which was disabled by accident diff --git a/shared/pwsave.py b/shared/pwsave.py index c2159da6..5342a84b 100644 --- a/shared/pwsave.py +++ b/shared/pwsave.py @@ -36,66 +36,61 @@ class PassphraseSaver: def _read(self, card): # Return a list of saved passphrases, or empty list if fail. # Fail silently in all cases. Expect to see lots of noise here. + assert self.key decrypt = ngu.aes.CTR(self.key) try: fname = self.filename(card) - msg = open(fname, 'rb').read() + with open(fname, 'rb') as f: + msg = f.read() txt = decrypt.cipher(msg) return ujson.loads(txt) except: return [] - async def read_and_save(self): - from glob import dis + async def _save(self, card, data): + assert self.key + encrypt = ngu.aes.CTR(self.key) + msg = encrypt.cipher(ujson.dumps(data)) - while 1: - dis.fullscreen('Saving...') - - try: - with CardSlot() as card: - self._calc_key(card) - - data = self._read(card) if self.key else [] - yield data # yield data that can be modified - - encrypt = ngu.aes.CTR(self.key) - - msg = encrypt.cipher(ujson.dumps(data)) - - with open(self.filename(card), 'wb') as fd: - fd.write(msg) - - await ux_dramatic_pause("Saved.", 1) - return - - except CardMissingError: - ch = await needs_microsd() - if ch == 'x': # undocumented, but needs escape route - break + # overwrites whatever already there + with open(self.filename(card), 'wb') as fd: + fd.write(msg) async def delete(self, idx): - c = self.read_and_save() - data = next(c) - del data[idx] - # resume generator - save try: - next(c) - except StopIteration: pass - if not data: - return True + with CardSlot() as card: + self._calc_key(card) + data = self._read(card) + + try: + del data[idx] + except IndexError: pass + + await self._save(card, data) + if not data: + return True # is empty + + except CardMissingError: + await needs_microsd() async def append(self, xfp, bip39pw): - c = self.read_and_save() - data = next(c) - to_add = dict(xfp=xfp, pw=bip39pw) - if to_add not in data: - data.append(to_add) - # resume generator - save - try: - next(c) - except StopIteration: pass + from glob import dis + dis.fullscreen('Reading...') + try: + with CardSlot() as card: + self._calc_key(card) + data = self._read(card) + + to_add = dict(xfp=xfp, pw=bip39pw) + if to_add not in data: + dis.fullscreen('Saving...') + data.append(to_add) + await self._save(card, data) + + except CardMissingError: + await needs_microsd() class PassphraseSaverMenu(MenuSystem): @@ -160,8 +155,11 @@ class PassphraseSaverMenu(MenuSystem): @staticmethod async def delete_entry(menu, idx, item): from ux import the_ux + from glob import dis + pw_saver, i = item.arg if await ux_confirm("Delete saved passphrase?"): + dis.fullscreen("Wait...") is_empty = await pw_saver.delete(i) the_ux.pop() if not is_empty: From b20a1c24198a1e76e7e9b1cc7dd55de501ff267f Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Fri, 19 Jan 2024 10:56:42 -0500 Subject: [PATCH 31/52] edge added --- releases/signatures.txt | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/releases/signatures.txt b/releases/signatures.txt index 8d598adf..9c5e8f9b 100644 --- a/releases/signatures.txt +++ b/releases/signatures.txt @@ -5,6 +5,7 @@ Hash: SHA256 8f71336a78573ccbd19b782e4f6e5930a8f944517884fe02d26eb0f38bf5c3ac Mk3ChangeLog.md a0c4d0ac3881a36704f0b620a13c72704531f656ee29368d5aac87dc5f21c7a1 History.md e70015cb0964b1b3ec7a77d119f9bb22dbbf56324671b6dc26122af16a7a7060 ChangeLog.md +a9d0b416c3cb4f122f2826283fce82bbc5fe4464817b601a3a5787b1f8aaba20 2024-01-18T1507-v6.2.2X-mk4-coldcard.dfu 4651fb81dc04ac07ae53535f4246ef7f32611c50853de9edaefa68f3c64e1fac 2023-12-21T1526-v5.2.2-mk4-coldcard.dfu a49cd00808732c67b359c9f86814ddeafc63a1040823b6c1d2035a870575c9ed 2023-12-21T1526-v5.2.2-mk4-coldcard-factory.dfu 06d1048bea43c5d7c72c5e5f395a676620ce884aed0cd152627a86d922e2f3ab 2023-12-19T1444-v5.2.1-mk4-coldcard.dfu @@ -51,12 +52,12 @@ f05bc8dfed047c0c0abe5ed60621d2d14899b10717221c4af0942d96a1754f33 2021-03-29T192 bea27f263b524a66b3ed0a58c16805e98be0d7c3db20c2f7aab3238f2c6a6995 2019-12-19T1623-v3.0.6-coldcard.dfu -----BEGIN PGP SIGNATURE----- -iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmWEWSgACgkQo6MbrVoq -WxClQAf/drgk06wrmERHRf5Q55NgISA4zBlm0yCSzaSdkCvKFB7yql8B1YBxH+5S -4/dFIrqZABV86IU1fVhd7nbZEi2AgfdpWPrzLHZAwsvBeqApAr757SzBLDDQ2r0W -aqwX82qzNTRIkaYtE9qDuaR0fCOysKMR8SwpdkjLOrzXo1EQZgYng6mW899hbBQo -Ekxq0tGIV+j1VnyTvy0HQ2Ip2d3Nf/1vr74xfVXQ204JsitWd2UgN0zZoXz2Nt4N -8G8ZuCQvgj99wKtc6H240DYC7oo+/b9QyapbQNWjhrPmq70MvStkU/1eKEFoy+WY -hfyM6Nb6PNkLjxd2lN/Bu4pVHvBAKg== -=mhUl +iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmWqm5kACgkQo6MbrVoq +WxCO2Af/clr4LJjUvcy5r94y09J3/Z47qkeh6lsrLOe4aRGGbCykNQkp6iMC0A0+ +YKoD3zC1bcKOgIP/UCsXcNghb0ZNjA+650iy3uQx1DwOJPjuHN1HbFXjGykiKGjd +FBRqKrylhZJy6GJzb+Gwr6VcWS1CVdu210VBRqwndYb25/Q177lvoRgp6dVYe+wj +JFadBr1qKm9zNfQIHyJO23ybaFvb0VQtBQk2F1oc8AvwU7FySx7Jnv4ZLY6vc7RM +LBB2AfLr2T+kLZ3bQvx0lxMrcq7M5BjntxvnsvmMbZbk/8zMcbbIeANnX1RK2O0K +rHPg49pUsmu1mXKHNvk8KP9R+1KCOg== +=ja1Y -----END PGP SIGNATURE----- From 209cdba8979479d422affe0588f64768174d4e8f Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Thu, 18 Jan 2024 12:15:13 +0100 Subject: [PATCH 32/52] pwsave improve exception handling --- shared/pwsave.py | 78 ++++++++++++++++++++++++------------------------ shared/seed.py | 10 ++++++- 2 files changed, 48 insertions(+), 40 deletions(-) diff --git a/shared/pwsave.py b/shared/pwsave.py index 5342a84b..58ddf8fc 100644 --- a/shared/pwsave.py +++ b/shared/pwsave.py @@ -5,7 +5,7 @@ import stash, ujson, ngu, pyb, os from files import CardSlot, CardMissingError, needs_microsd from ux import ux_dramatic_pause, ux_confirm, ux_show_story -from utils import xfp2str +from utils import xfp2str, problem_file_line from menu import MenuItem, MenuSystem @@ -59,38 +59,30 @@ class PassphraseSaver: fd.write(msg) async def delete(self, idx): - try: - with CardSlot() as card: - self._calc_key(card) - data = self._read(card) + with CardSlot() as card: + self._calc_key(card) + data = self._read(card) - try: - del data[idx] - except IndexError: pass + try: + del data[idx] + except IndexError: pass - await self._save(card, data) - if not data: - return True # is empty - - except CardMissingError: - await needs_microsd() + await self._save(card, data) + if not data: + return True # is empty async def append(self, xfp, bip39pw): from glob import dis dis.fullscreen('Reading...') - try: - with CardSlot() as card: - self._calc_key(card) - data = self._read(card) + with CardSlot() as card: + self._calc_key(card) + data = self._read(card) - to_add = dict(xfp=xfp, pw=bip39pw) - if to_add not in data: - dis.fullscreen('Saving...') - data.append(to_add) - await self._save(card, data) - - except CardMissingError: - await needs_microsd() + to_add = dict(xfp=xfp, pw=bip39pw) + if to_add not in data: + dis.fullscreen('Saving...') + data.append(to_add) + await self._save(card, data) class PassphraseSaverMenu(MenuSystem): @@ -160,20 +152,28 @@ class PassphraseSaverMenu(MenuSystem): pw_saver, i = item.arg if await ux_confirm("Delete saved passphrase?"): dis.fullscreen("Wait...") - is_empty = await pw_saver.delete(i) - the_ux.pop() - if not is_empty: - m = the_ux.top_of_stack() - m.update_contents() - else: - # remove .tmp.tmp file after last passphrase - # is deleted - with CardSlot() as card: - f_path = pw_saver.filename(card) - os.remove(f_path) + try: + is_empty = await pw_saver.delete(i) the_ux.pop() - m = the_ux.top_of_stack() - m.update_contents() + if not is_empty: + m = the_ux.top_of_stack() + m.update_contents() + else: + # remove .tmp.tmp file after last passphrase + # is deleted + with CardSlot() as card: + f_path = pw_saver.filename(card) + os.remove(f_path) + the_ux.pop() + m = the_ux.top_of_stack() + m.update_contents() + except CardMissingError: + await needs_microsd() + except Exception as e: + await ux_show_story( + title="ERROR", + msg='Delete failed!\n\n%s\n%s' % (e, problem_file_line(e)) + ) @classmethod def construct(cls): diff --git a/shared/seed.py b/shared/seed.py index c59c4a84..0758fdb8 100644 --- a/shared/seed.py +++ b/shared/seed.py @@ -1265,7 +1265,15 @@ class PassphraseMenu(MenuSystem): await set_ephemeral_seed(nv, summarize_ux=False, bip39pw=pp_sofar, meta="BIP-39 Passphrase on [%s]" % parent_xfp_str) if ch == '1': - await PassphraseSaver().append(xfp, pp_sofar) + try: + await PassphraseSaver().append(xfp, pp_sofar) + except CardMissingError: + await needs_microsd() + except Exception as e: + await ux_show_story( + title="ERROR", + msg='Save failed!\n\n%s\n%s' % (e, problem_file_line(e)) + ) goto_top_menu() From ebc583097729f3264f8c7011ce23f2d7739953c3 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Sat, 3 Feb 2024 17:54:21 +0100 Subject: [PATCH 33/52] bugfix: final flag was missing in framing error response; properly handle framing errors --- releases/ChangeLog.md | 1 + shared/multisig.py | 2 +- shared/usb.py | 12 +++++++----- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index a6260622..7e35d918 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -1,6 +1,7 @@ ## 5.2.3 - 2024-XX-XX - Bugfix: Saving passphrase on SD Card caused a freeze that required reboot +- Bugfix: Properly handle and finalize framing error response ## 5.2.2 - 2023-12-21 diff --git a/shared/multisig.py b/shared/multisig.py index 31c1aa8e..b5786052 100644 --- a/shared/multisig.py +++ b/shared/multisig.py @@ -694,7 +694,7 @@ class MultisigWallet: # expect_chain = chains.current_chain().ctype if "sortedmulti(" in config or MultisigDescriptor.is_descriptor(config): - # assume descriptor, classic config should not contain sertedmulti( and check for checksum separator + # assume descriptor, classic config should not contain sortedmulti( and check for checksum separator # ignore name _, addr_fmt, xpubs, has_mine, M, N = cls.from_descriptor(config) else: diff --git a/shared/usb.py b/shared/usb.py index 4715e0f9..7d4383a6 100644 --- a/shared/usb.py +++ b/shared/usb.py @@ -237,6 +237,8 @@ class USBHandler: # prefer to catch at higher layers, but sometimes can't resp = b'err_Out of RAM' msg_len = 0 + except FramingError as exc: + raise exc except Exception as exc: # catch bugs and fuzzing too if is_simulator() or is_devmode: @@ -250,8 +252,8 @@ class USBHandler: except FramingError as exc: reason = exc.args[0] - #print("Framing: %s" % reason) - self.framing_error(reason) + # print("Framing: %s" % reason) + await self.framing_error(reason) msg_len = 0 except BaseException as exc: @@ -326,10 +328,10 @@ class USBHandler: # Let other stuff run during this delay. await sleep_ms(10) - def framing_error(self, why): + async def framing_error(self, why): # send error about framing, and recover - self.dev.send(b'%cfram%-59s' % (4+len(why), why)) - + resp = b'fram' + why.encode() + await self.send_response(resp) async def handle(self, cmd, args): # Dispatch incoming message, and provide reply. From 0c4977af91d4dbf8d35718a3854c237ac139994c Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Wed, 31 Jan 2024 11:38:33 +0100 Subject: [PATCH 34/52] bugfix: brick me option for If Wrong PIN lacks num arguemnt which caused yikes --- releases/ChangeLog.md | 1 + shared/trick_pins.py | 2 +- testing/test_se2.py | 27 +++++++++++++++++++++++++-- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index 7e35d918..d5f2abfd 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -2,6 +2,7 @@ - Bugfix: Saving passphrase on SD Card caused a freeze that required reboot - Bugfix: Properly handle and finalize framing error response +- Bugfix: `Brick Me` option for `If Wrong` PIN caused yikes ## 5.2.2 - 2023-12-21 diff --git a/shared/trick_pins.py b/shared/trick_pins.py index 65125917..3f5566e9 100644 --- a/shared/trick_pins.py +++ b/shared/trick_pins.py @@ -633,7 +633,7 @@ setting) the Coldcard will always brick after 13 failed PIN attempts.''') arg=num, flags=TC_WIPE|TC_REBOOT), StoryMenuItem('Silent Wipe', "Seed is silently wiped and Coldcard acts as if PIN code was just wrong.", arg=num, flags=TC_WIPE|TC_FAKE_OUT), - StoryMenuItem('Brick Self', "Become a brick instantly and forever.", flags=TC_BRICK), + StoryMenuItem('Brick Self', "Become a brick instantly and forever.", flags=TC_BRICK, arg=num), StoryMenuItem('Last Chance', "Wipe seed, then give one more try and then brick if wrong PIN.", arg=num, flags=TC_WIPE|TC_BRICK), StoryMenuItem('Just Reboot', "Reboot when this happens. Doesn't do anything else.", arg=num, flags=TC_REBOOT), ]) diff --git a/testing/test_se2.py b/testing/test_se2.py index 3fc386e5..ea3b9471 100644 --- a/testing/test_se2.py +++ b/testing/test_se2.py @@ -367,6 +367,18 @@ def test_ux_wrong_pin(num_wrong, op_mode, expect, xflags, enter_number, goto_trick_menu, new_pin_confirmed, need_keypress, enter_pin): # wrong pin choices, not implementation goto_trick_menu() + m = cap_menu() + + if not ('Add If Wrong' in m): + # already has "if wrong" + pick_menu_item('↳WRONG PIN') + pick_menu_item('Delete Trick') + time.sleep(.1) + _, story = cap_story() + assert "Are you SURE" in story + assert "Remove special handling of wrong PINs?" in story + need_keypress("y") + time.sleep(.1) pick_menu_item('Add If Wrong') time.sleep(.1) @@ -379,9 +391,12 @@ def test_ux_wrong_pin(num_wrong, op_mode, expect, xflags, enter_number, time.sleep(.1) m = cap_menu() + real_num_wrong = num_wrong if num_wrong <= 1: + real_num_wrong = 1 assert m[0] == '[ANY WRONG PIN]' elif num_wrong >= 12: + real_num_wrong = 12 assert m[0] == '[12th WRONG PIN]' else: assert m[0][0:2] == f'[{num_wrong}' @@ -394,9 +409,17 @@ def test_ux_wrong_pin(num_wrong, op_mode, expect, xflags, enter_number, assert expect in story time.sleep(.1) - need_keypress('x') + need_keypress('y') time.sleep(.1) - need_keypress('x') + _, story = cap_story() + assert f"{real_num_wrong} Wrong PINs" in story + assert op_mode in story + assert "Ok?" in story + need_keypress('y') + time.sleep(.1) + m = cap_menu() + assert 'Add If Wrong' not in m + @pytest.mark.parametrize('subchoice, expect, xflags', [ ( 'Wipe & Reboot', 'wiped and Coldcard reboots', TC_WIPE|TC_REBOOT ), From 2d2f8f3d9dc1fe85f745c5f010a059f29e2a7948 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Mon, 5 Feb 2024 15:08:46 +0100 Subject: [PATCH 35/52] bugfix: multisig ascii input validation --- releases/ChangeLog.md | 2 ++ shared/auth.py | 19 +++---------------- shared/multisig.py | 4 ++-- shared/utils.py | 30 ++++++++++++++++++++++++++---- testing/test_msg.py | 13 +++++++++---- testing/test_multisig.py | 21 +++++++++++++++++++-- 6 files changed, 61 insertions(+), 28 deletions(-) diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index d5f2abfd..8cf1c864 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -3,6 +3,8 @@ - Bugfix: Saving passphrase on SD Card caused a freeze that required reboot - Bugfix: Properly handle and finalize framing error response - Bugfix: `Brick Me` option for `If Wrong` PIN caused yikes +- Bugfix: Do not allow non-ascii or ascii non-printable characters in multisig + wallet name ## 5.2.2 - 2023-12-21 diff --git a/shared/auth.py b/shared/auth.py index bb5c4989..1bf0ac56 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -15,7 +15,7 @@ from ux import ux_aborted, ux_show_story, abort_and_goto, ux_dramatic_pause, ux_ from ux import show_qr_code from usb import CCBusyError from utils import HexWriter, xfp2str, problem_file_line, cleanup_deriv_path -from utils import B2A, parse_addr_fmt_str +from utils import B2A, parse_addr_fmt_str, to_ascii_printable from psbt import psbtObject, FatalPSBTIssue, FraudulentChangeOutput from exceptions import HSMDenied from version import MAX_TXN_LEN @@ -278,27 +278,14 @@ def validate_text_for_signing(text): # - messages must be short and ascii only. Our charset is limited # - too many spaces, leading/trailing can be an issue - MSG_CHARSET = range(32, 127) MSG_MAX_SPACES = 4 # impt. compared to -=- positioning - try: - result = str(text, 'ascii') - except UnicodeError: - raise AssertionError('must be ascii') + result = to_ascii_printable(text) length = len(result) assert length >= 2, "msg too short (min. 2)" assert length <= MSG_SIGNING_MAX_LENGTH, "msg too long (max. %d)" % MSG_SIGNING_MAX_LENGTH - run = 0 - for ch in result: - assert ord(ch) in MSG_CHARSET, "bad char: 0x%02x in msg" % ord(ch) - - if ch == ' ': - run += 1 - assert run < MSG_MAX_SPACES, 'too many spaces together in msg(max. 4)' - else: - run = 0 - + assert " " not in result, 'too many spaces together in msg(max. 3)' # other confusion w/ whitepace assert result[0] != ' ', 'leading space(s) in msg' assert result[-1] != ' ', 'trailing space(s) in msg' diff --git a/shared/multisig.py b/shared/multisig.py index b5786052..b6b7f9a8 100644 --- a/shared/multisig.py +++ b/shared/multisig.py @@ -3,7 +3,7 @@ # multisig.py - support code for multisig signing and p2sh in general. # import stash, chains, ustruct, ure, uio, sys, ngu, uos, ujson -from utils import xfp2str, str2xfp, swab32, cleanup_deriv_path, keypath_to_str +from utils import xfp2str, str2xfp, swab32, cleanup_deriv_path, keypath_to_str, to_ascii_printable from utils import str_to_keypath, problem_file_line, export_prompt_builder, parse_extended_key from ux import ux_show_story, ux_confirm, ux_dramatic_pause, ux_clear_keys, ux_enter_bip32_index from files import CardSlot, CardMissingError, needs_microsd @@ -716,7 +716,7 @@ class MultisigWallet: name = '%d-of-%d' % (M, N) try: - name = str(name, 'ascii') + name = to_ascii_printable(name) assert 1 <= len(name) <= 20 except: raise AssertionError('name must be ascii, 1..20 long') diff --git a/shared/utils.py b/shared/utils.py index d58b7eba..21563811 100644 --- a/shared/utils.py +++ b/shared/utils.py @@ -175,6 +175,30 @@ def str2xfp(txt): # Inverse of xfp2str return ustruct.unpack(' Date: Mon, 5 Feb 2024 16:41:38 +0100 Subject: [PATCH 36/52] submodule: ckcc-protocol move to f924f6d35ca0a6804b9e25d476cb53ae2f8ae8d6 --- external/ckcc-protocol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/external/ckcc-protocol b/external/ckcc-protocol index 52b59501..f924f6d3 160000 --- a/external/ckcc-protocol +++ b/external/ckcc-protocol @@ -1 +1 @@ -Subproject commit 52b5950105af3c40dc2e6ab7c0b3a161667db787 +Subproject commit f924f6d35ca0a6804b9e25d476cb53ae2f8ae8d6 From 89d7cca418bc58a55d7230382c0b9bc84cc1079a Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Mon, 5 Feb 2024 16:51:19 +0100 Subject: [PATCH 37/52] docs: update limitations.md with multisig name limitations --- docs/limitations.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/limitations.md b/docs/limitations.md index 2967a050..da1885d0 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -71,6 +71,7 @@ the active multisig wallet, and cannot be used to describe an unrelated (multisig) wallet. - derivation path for each cosigner must be known and consistent with PSBT - XFP values (fingerprints) MUST be unique for each of the co-signers +- multisig wallet `name` can only contain printable ASCII characters `range(32, 127)` # SIGHASH types From b0957d770fdd455aab1b3b3ee6cec2505e5d7ede Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Tue, 6 Feb 2024 14:30:57 +0100 Subject: [PATCH 38/52] testing: test_se2.py::test_ux_wrong_pin delete TP at the end to preserve memory for next tests --- testing/test_se2.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/testing/test_se2.py b/testing/test_se2.py index ea3b9471..570dba91 100644 --- a/testing/test_se2.py +++ b/testing/test_se2.py @@ -367,19 +367,6 @@ def test_ux_wrong_pin(num_wrong, op_mode, expect, xflags, enter_number, goto_trick_menu, new_pin_confirmed, need_keypress, enter_pin): # wrong pin choices, not implementation goto_trick_menu() - m = cap_menu() - - if not ('Add If Wrong' in m): - # already has "if wrong" - pick_menu_item('↳WRONG PIN') - pick_menu_item('Delete Trick') - time.sleep(.1) - _, story = cap_story() - assert "Are you SURE" in story - assert "Remove special handling of wrong PINs?" in story - need_keypress("y") - time.sleep(.1) - pick_menu_item('Add If Wrong') time.sleep(.1) _, story = cap_story() @@ -419,6 +406,14 @@ def test_ux_wrong_pin(num_wrong, op_mode, expect, xflags, enter_number, time.sleep(.1) m = cap_menu() assert 'Add If Wrong' not in m + pick_menu_item('↳WRONG PIN') + pick_menu_item('Delete Trick') + time.sleep(.1) + _, story = cap_story() + assert "Are you SURE" in story + assert "Remove special handling of wrong PINs?" in story + need_keypress("y") + time.sleep(.1) @pytest.mark.parametrize('subchoice, expect, xflags', [ From 4880292eae3240a3bd2874540073f01c30ae9272 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Sun, 24 Dec 2023 12:56:15 +0100 Subject: [PATCH 39/52] always use SettingsObject class for master data --- shared/pincodes.py | 5 +++-- shared/seed.py | 8 ++++---- testing/devtest/clear_seed.py | 5 +++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/shared/pincodes.py b/shared/pincodes.py index 15797d3d..2d069fa6 100644 --- a/shared/pincodes.py +++ b/shared/pincodes.py @@ -479,6 +479,7 @@ class PinAttempt: def tmp_secret(self, encoded, chain=None, bip39pw=''): # Use indicated secret and stop using the SE; operate like this until reboot from glob import settings + from nvstore import SettingsObject val = bytes(encoded + bytes(AE_SECRET_LEN - len(encoded))) if self.tmp_value == val: @@ -490,9 +491,9 @@ class PinAttempt: # disallow using master seed as temporary master_err = "Cannot use master seed as temporary." target_nvram_key = settings.hash_key(encoded) - if settings.master_nvram_key: + if SettingsObject.master_nvram_key: assert self.tmp_value - if target_nvram_key == settings.master_nvram_key: + if target_nvram_key == SettingsObject.master_nvram_key: return False, master_err else: if target_nvram_key == settings.nvram_key: diff --git a/shared/seed.py b/shared/seed.py index 0758fdb8..f41137f0 100644 --- a/shared/seed.py +++ b/shared/seed.py @@ -693,8 +693,8 @@ async def remember_ephemeral_seed(): # old master settings are destroyed dis.fullscreen("Cleanup...") assert pa.tmp_value, "no tmp" - assert settings.master_nvram_key, "master nvram k" - old_master = SettingsObject(settings.master_nvram_key) + assert SettingsObject.master_nvram_key, "master nvram k" + old_master = SettingsObject(SettingsObject.master_nvram_key) old_master.load() old_master.blank() del old_master @@ -703,8 +703,8 @@ async def remember_ephemeral_seed(): pa.change(new_secret=pa.tmp_value, tmp_lockdown=True) # not needed - will be handled by reboot - settings.master_nvram_key = None - settings.master_sv_data = {} + SettingsObject.master_nvram_key = None + SettingsObject.master_sv_data = {} # check and reload secret pa.reset() diff --git a/testing/devtest/clear_seed.py b/testing/devtest/clear_seed.py index 72ee3ede..baa50631 100644 --- a/testing/devtest/clear_seed.py +++ b/testing/devtest/clear_seed.py @@ -6,6 +6,7 @@ from pincodes import pa from glob import settings from pincodes import AE_SECRET_LEN, PA_IS_BLANK from sim_settings import sim_defaults +from nvstore import SettingsObject if not pa.is_secret_blank(): # clear settings associated with this key, since it will be no more @@ -24,8 +25,8 @@ if not pa.is_secret_blank(): assert pa.is_secret_blank() settings.blank() -settings.master_sv_data = {} -settings.master_nvram_key = None +SettingsObject.master_sv_data = {} +SettingsObject.master_nvram_key = None # reset top menu and go there from actions import goto_top_menu goto_top_menu() From 79d43075205ea95b64a30a2b8131047d44718f33 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Thu, 8 Feb 2024 16:02:48 -0500 Subject: [PATCH 40/52] added Q beta v0.0.3Q --- releases/signatures.txt | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/releases/signatures.txt b/releases/signatures.txt index 9c5e8f9b..dc0ac668 100644 --- a/releases/signatures.txt +++ b/releases/signatures.txt @@ -4,7 +4,9 @@ Hash: SHA256 715a3ec7a91d2366788b14a243e7343de875714b647d1e2bc906ecc5b752d8d9 README.md 8f71336a78573ccbd19b782e4f6e5930a8f944517884fe02d26eb0f38bf5c3ac Mk3ChangeLog.md a0c4d0ac3881a36704f0b620a13c72704531f656ee29368d5aac87dc5f21c7a1 History.md -e70015cb0964b1b3ec7a77d119f9bb22dbbf56324671b6dc26122af16a7a7060 ChangeLog.md +bf86232f94f40a3fef1e0e5e8dd1f184f7e2782606001a121f71a911c1eab95d ChangeLog.md +3064bf7f1a039e7cd5c1a13c6aff8cc4338e52ef2177abbdca4b196955f9e434 2024-02-08T2005-v0.0.3Q-q1-coldcard.dfu +788e7a1b182f920016617411b875fa7095ae007c6a53fc476afb1c93f0eed1c9 2024-02-08T2005-v0.0.3Q-q1-coldcard-factory.dfu a9d0b416c3cb4f122f2826283fce82bbc5fe4464817b601a3a5787b1f8aaba20 2024-01-18T1507-v6.2.2X-mk4-coldcard.dfu 4651fb81dc04ac07ae53535f4246ef7f32611c50853de9edaefa68f3c64e1fac 2023-12-21T1526-v5.2.2-mk4-coldcard.dfu a49cd00808732c67b359c9f86814ddeafc63a1040823b6c1d2035a870575c9ed 2023-12-21T1526-v5.2.2-mk4-coldcard-factory.dfu @@ -52,12 +54,12 @@ f05bc8dfed047c0c0abe5ed60621d2d14899b10717221c4af0942d96a1754f33 2021-03-29T192 bea27f263b524a66b3ed0a58c16805e98be0d7c3db20c2f7aab3238f2c6a6995 2019-12-19T1623-v3.0.6-coldcard.dfu -----BEGIN PGP SIGNATURE----- -iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmWqm5kACgkQo6MbrVoq -WxCO2Af/clr4LJjUvcy5r94y09J3/Z47qkeh6lsrLOe4aRGGbCykNQkp6iMC0A0+ -YKoD3zC1bcKOgIP/UCsXcNghb0ZNjA+650iy3uQx1DwOJPjuHN1HbFXjGykiKGjd -FBRqKrylhZJy6GJzb+Gwr6VcWS1CVdu210VBRqwndYb25/Q177lvoRgp6dVYe+wj -JFadBr1qKm9zNfQIHyJO23ybaFvb0VQtBQk2F1oc8AvwU7FySx7Jnv4ZLY6vc7RM -LBB2AfLr2T+kLZ3bQvx0lxMrcq7M5BjntxvnsvmMbZbk/8zMcbbIeANnX1RK2O0K -rHPg49pUsmu1mXKHNvk8KP9R+1KCOg== -=ja1Y +iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmXFQWEACgkQo6MbrVoq +WxDr/AgAmDIvMg5pdrqoMP+qiS3Gd2UgwBRDyU1IkTkwP73f50dksCROz2G74Rh5 ++A0YYcKiB2FjZKBPGFwfwBO8fiuqeNoYIZtXzObWJJk1gHEFmRSKFnmQ0qCoc1l1 +vl2adlOIMG6D7GB7Ilxqb0/JvbKMSTSY6fLiAy2UZmL8cfIneMvm1HrI+jmuuZze +lKraKUksHZV4vwXvhr6z0B0L/lqMW5ZMfIAcYulE7eQmgY4Y8jM4von3v/YsD4Od +e8J2JaSXF8xwZQMob6ILVoq2AEvH5AxtAtgilYK+IuDdCashqOcEW2wgYTxNFKYu +JkNveZGe67gsZfbWDcxN3c8SEYejww== +=OoZB -----END PGP SIGNATURE----- From 8e43f6f4fc489788b8bf9e5507e664e4e6d66aab Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Sat, 24 Feb 2024 20:56:11 +0100 Subject: [PATCH 41/52] bugfix: reads in final 3 byte of file could return incorrect data --- releases/ChangeLog.md | 2 ++ shared/sffile.py | 37 ++++++++++++++++++++----------------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index 8cf1c864..2417519e 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -5,6 +5,8 @@ - Bugfix: `Brick Me` option for `If Wrong` PIN caused yikes - Bugfix: Do not allow non-ascii or ascii non-printable characters in multisig wallet name +- Bugfix: Very obscure bug in low level code could cause txid to be miscalculated + if all the conditions occured just right ## 5.2.2 - 2023-12-21 diff --git a/shared/sffile.py b/shared/sffile.py index 88252fce..e71809c5 100644 --- a/shared/sffile.py +++ b/shared/sffile.py @@ -1,29 +1,25 @@ # (c) Copyright 2018 by Coinkite Inc. This file is covered by license found in COPYING-CC. # -# sffile.py - file-like objects stored in SPI Flash (Mk1-3) or PSRAM (Mk4+) +# sffile.py - file-like objects stored in PSRAM (Mk4+) (used to be SPI Flash) # # - implements stream IO protoccol -# - does erasing for you # - random read, sequential write # - only a few of these are possible # - the offset is the file name -# - ( self.wr_pos: + # put the runt data into place, because we are about to read it + t = bytearray(self.runt) + t.extend(bytes(4-len(t))) + PSRAM.write(self.start + self.wr_pos, t) + PSRAM.read(self.start + self.pos, rv) self.pos += ll From 71c9417d7586478046d12c7e5fc6a748d11ba44b Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Mon, 26 Feb 2024 14:58:39 -0500 Subject: [PATCH 42/52] Add 608C support --- shared/callgate.py | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/shared/callgate.py b/shared/callgate.py index bf5528e8..2fbda61e 100644 --- a/shared/callgate.py +++ b/shared/callgate.py @@ -85,10 +85,17 @@ def set_highwater(ts): def has_608(): return ckcc.gate(6, None, 0) == 0 -def has_608b(): +def get_608_rev(): + # return A, B, C and so on config = bytearray(128) ckcc.gate(20, config, 0) - return (config[7] >= 0x3) + if config[7] < 0x3: + return 'A' + if config[7] == 0x3: + return 'B' + if config[7] == 0x4: + return 'C' + return '?' def fast_wipe(silent=True): # mk4: wipe seed, also reboots immediately: can stop and show a screen or not @@ -114,15 +121,17 @@ def read_rng(source=2): return arg[1:1+arg[0]] def get_se_parts(): - # mk4: report part names - # - gets a nul-terminated string, w/ newline between them - arg = bytearray(80) - rv = ckcc.gate(27, arg, 0); - if rv: - # happens w/ obsolete versions of bootrom that never left Toronto - return ['SE1', 'SE2'] - ln = bytes(arg).find(b'\0') - return arg[0:ln].decode().split('\n') - + # we know better than bootrom + return ['ATECC608'+get_608_rev(), 'DS28C36B'] + if 0: + # mk4: report part names + # - gets a nul-terminated string, w/ newline between them + arg = bytearray(80) + rv = ckcc.gate(27, arg, 0) + if rv: + # happens w/ obsolete versions of bootrom that never left Toronto + return ['SE1', 'SE2'] + ln = bytes(arg).find(b'\0') + return arg[0:ln].decode().split('\n') # EOF From 527d24a5fd646cb23d3c75aa3f6f03f146167fc2 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Tue, 27 Feb 2024 09:02:54 -0500 Subject: [PATCH 43/52] remove obsolete code --- shared/login.py | 19 +------------------ shared/pincodes.py | 6 ------ shared/selftest.py | 4 ---- 3 files changed, 1 insertion(+), 28 deletions(-) diff --git a/shared/login.py b/shared/login.py index 9c6014b6..f91cbb87 100644 --- a/shared/login.py +++ b/shared/login.py @@ -224,20 +224,6 @@ class LoginUX: self.show_pin() - async def do_delay(self): - # show # of failures and implement the delay, which could be - # very long. - dis.clear() - dis.text(None, 0, "Checking...", FontLarge) - dis.text(None, 24, 'Wait '+pretty_delay(pa.delay_required * pa.seconds_per_tick)) - dis.text(None, 40, "(%d failures)" % pa.num_fails) - - while pa.is_delay_needed(): - dis.progress_bar(pa.delay_achieved / pa.delay_required) - dis.show() - - pa.delay() - async def we_are_ewaste(self, num_fails): msg = '''After %d failed PIN attempts this Coldcard is locked forever. \ By design, there is no way to reset or recover the secure element, and its contents \ @@ -287,13 +273,10 @@ Press OK to continue, X to stop for now. dis.fullscreen("Wait...") pa.setup(pin, self.is_secondary) - if version.has_608 and pa.num_fails > 3: + if pa.num_fails > 3: # they are approaching brickage, so warn them each attempt await self.confirm_attempt(pa.attempts_left, pa.num_fails, pin) dis.fullscreen("Wait...") - elif pa.is_delay_needed(): - # mark 1/2 might come here, never mark3 - await self.do_delay() # do the actual login attempt now try: diff --git a/shared/pincodes.py b/shared/pincodes.py index 2d069fa6..3514cfbf 100644 --- a/shared/pincodes.py +++ b/shared/pincodes.py @@ -308,12 +308,6 @@ class PinAttempt: return rv - def is_delay_needed(self): - # obsolete starting w/ mk3 and values re-used for other stuff - if version.has_608: - return False - return self.delay_achieved < self.delay_required - def is_blank(self): # device has no PIN at this point return bool(self.state_flags & PA_IS_BLANK) diff --git a/shared/selftest.py b/shared/selftest.py index fc780a46..ad5f955e 100644 --- a/shared/selftest.py +++ b/shared/selftest.py @@ -34,13 +34,9 @@ def set_genuine(): # - or logged in already as main from pincodes import pa - if pa.is_secondary: - return - if not pa.is_successful(): # assume blank pin during factory selftest pa.setup(b'') - assert not pa.is_delay_needed() # "PIN failures?" if not pa.is_successful(): pa.login() From 5173a4e533e5851c51c946e626d7c0c31310d278 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Tue, 27 Feb 2024 09:15:30 -0500 Subject: [PATCH 44/52] remove obsolete code --- shared/actions.py | 38 -------------------------------------- shared/login.py | 44 ++++++++++++-------------------------------- shared/pincodes.py | 2 +- 3 files changed, 13 insertions(+), 71 deletions(-) diff --git a/shared/actions.py b/shared/actions.py index f7803297..b76156b0 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -756,44 +756,6 @@ async def export_seedqr(*a): stash.blank_object(qr) -async def damage_myself(): - # called when it's time to disable ourselves due to various - # features related to duress and so on - # - mk2 cannot do this - # - mk4 doesn't call this, done by bootrom - mode = settings.get('cd_mode', 0) - #['Brick', 'Final PIN', 'Test Mode'] - - if mode == 2: - # test mode, do no damage - return - - from glob import dis - dis.fullscreen("Wait...") - dis.busy_bar(True) - - if mode == 1: - # leave single attempt; careful! - # - always consume one attempt, regardless - todo = max(1, pa.attempts_left - 1) - else: - # brick ourselves, by consuming all PIN attempts - todo = pa.attempts_left - - # do a bunch of failed attempts - pa.setup('hfsp', False) - for i in range(todo): - try: - pa.login() - except: - # expecting EPIN_AUTH_FAIL - pass - - # Try to keep UX responsive? But callgate stuff blocks everything, - # so just go as fast as possible. - - dis.busy_bar(False) - async def version_migration(): # Handle changes between upgrades, and allow downgrades when possible. # - long term we generally cannot delete code from here, because we diff --git a/shared/login.py b/shared/login.py index f91cbb87..ab31f033 100644 --- a/shared/login.py +++ b/shared/login.py @@ -2,8 +2,6 @@ # # login.py - UX related to PIN code entry/login. # -# NOTE: Mark3+ hardware does not support secondary wallet concept. -# import pincodes, version, random from glob import dis from display import FontLarge, FontTiny @@ -23,7 +21,6 @@ class LoginUX: self.is_repeat = False self.subtitle = False self.kill_btn = kill_btn - self.offer_second = not version.has_608 self.reset() self.randomize = randomize @@ -36,7 +33,6 @@ class LoginUX: self.pin = '' # just the part we're showing self.pin_prefix = None self.words_ok = False - self.is_secondary = False self.footer = None def show_pin_randomized(self, force_draw): @@ -121,7 +117,7 @@ class LoginUX: dis.show() - def _show_words(self, has_secondary=False): + def _show_words(self): dis.clear() dis.text(None, 0, "Recognize these?" if (not self.is_setting) or self.is_repeat \ @@ -136,10 +132,7 @@ class LoginUX: dis.text(x, y, words[0], FontLarge) dis.text(x, y+18, words[1], FontLarge) - if self.offer_second: - dis.text(None, -1, "Press (2) for secondary wallet", FontTiny) - else: - dis.text(None, -1, "X to CANCEL, or OK to CONTINUE", FontTiny) + dis.text(None, -1, "X to CANCEL, or OK to CONTINUE", FontTiny) dis.busy_bar(False) # includes a dis.show() #dis.show() @@ -187,8 +180,6 @@ class LoginUX: self._show_words() pattern = 'xy' - if self.offer_second: - pattern += '2' if self.kill_btn: pattern += self.kill_btn @@ -203,7 +194,6 @@ class LoginUX: if nxt == 'y' or nxt == '2': self.pin_prefix = self.pin self.pin = '' - self.is_secondary = (nxt == '2') if self.randomize: self.shuffle_keys() @@ -253,16 +243,14 @@ Press OK to continue, X to stop for now. async def try_login(self, bypass_pin=None): while 1: - if version.has_608 and not pa.attempts_left: + if not pa.attempts_left: # tell them it's futile await self.we_are_ewaste(pa.num_fails) self.reset() if pa.num_fails: - self.footer = '%d failures' % pa.num_fails - if version.has_608: - self.footer += ', %d tries left' % pa.attempts_left + self.footer = '%d failures, %d tries left' % (pa.num_fails, pa.attempts_left) pin = await self.interact() @@ -271,7 +259,7 @@ Press OK to continue, X to stop for now. continue dis.fullscreen("Wait...") - pa.setup(pin, self.is_secondary) + pa.setup(pin) if pa.num_fails > 3: # they are approaching brickage, so warn them each attempt @@ -298,24 +286,17 @@ Press OK to continue, X to stop for now. dis.busy_bar(False) pa.num_fails += 1 - if version.has_608: - pa.attempts_left -= 1 + pa.attempts_left -= 1 - msg = "" + if not pa.attempts_left: + await self.we_are_ewaste(pa.num_fails) + continue + + msg = '%d attempts left' % (pa.attempts_left) nf = '1 failure' if pa.num_fails <= 1 else ('%d failures' % pa.num_fails) - if version.has_608: - if not pa.attempts_left: - await self.we_are_ewaste(pa.num_fails) - continue - - msg += '%d attempts left' % (pa.attempts_left) - else: - msg += '%s' % nf msg += '''\n\nPlease check all digits carefully, and that prefix versus \ -suffix break point is correct.''' - if version.has_608: - msg += '\n\n' + nf +suffix break point is correct.\n\n''' + nf await ux_show_story(msg, title='WRONG PIN') @@ -328,7 +309,6 @@ suffix break point is correct.''' async def get_new_pin(self, title, story=None, allow_clear=False): # Do UX flow to get new (or change) PIN. Always does the double-entry thing self.is_setting = True - self.offer_second = False if story: # give them background diff --git a/shared/pincodes.py b/shared/pincodes.py index 3514cfbf..05d11ed9 100644 --- a/shared/pincodes.py +++ b/shared/pincodes.py @@ -71,7 +71,7 @@ EPIN_OLD_AUTH_FAIL = const(-113) # We are round-tripping this big structure, partially signed by bootloader. ''' uint32_t magic_value; // = PA_MAGIC_V2 or V1 for older bootroms - int is_secondary; // (bool) primary or secondary + int is_secondary; // (bool) primary or secondary OBSOLETE char pin[MAX_PIN_LEN]; // value being attempted int pin_len; // valid length of pin uint32_t delay_achieved; // so far, how much time wasted? [508a only] From 316e4aff8af7ada1b86264ba606f53d201ae3444 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Tue, 27 Feb 2024 10:43:22 -0500 Subject: [PATCH 45/52] Revert "remove obsolete code" This reverts commit 527d24a5fd646cb23d3c75aa3f6f03f146167fc2. --- shared/login.py | 19 ++++++++++++++++++- shared/pincodes.py | 6 ++++++ shared/selftest.py | 4 ++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/shared/login.py b/shared/login.py index ab31f033..a5a3c24c 100644 --- a/shared/login.py +++ b/shared/login.py @@ -214,6 +214,20 @@ class LoginUX: self.show_pin() + async def do_delay(self): + # show # of failures and implement the delay, which could be + # very long. + dis.clear() + dis.text(None, 0, "Checking...", FontLarge) + dis.text(None, 24, 'Wait '+pretty_delay(pa.delay_required * pa.seconds_per_tick)) + dis.text(None, 40, "(%d failures)" % pa.num_fails) + + while pa.is_delay_needed(): + dis.progress_bar(pa.delay_achieved / pa.delay_required) + dis.show() + + pa.delay() + async def we_are_ewaste(self, num_fails): msg = '''After %d failed PIN attempts this Coldcard is locked forever. \ By design, there is no way to reset or recover the secure element, and its contents \ @@ -261,10 +275,13 @@ Press OK to continue, X to stop for now. dis.fullscreen("Wait...") pa.setup(pin) - if pa.num_fails > 3: + if version.has_608 and pa.num_fails > 3: # they are approaching brickage, so warn them each attempt await self.confirm_attempt(pa.attempts_left, pa.num_fails, pin) dis.fullscreen("Wait...") + elif pa.is_delay_needed(): + # mark 1/2 might come here, never mark3 + await self.do_delay() # do the actual login attempt now try: diff --git a/shared/pincodes.py b/shared/pincodes.py index 05d11ed9..a439390c 100644 --- a/shared/pincodes.py +++ b/shared/pincodes.py @@ -308,6 +308,12 @@ class PinAttempt: return rv + def is_delay_needed(self): + # obsolete starting w/ mk3 and values re-used for other stuff + if version.has_608: + return False + return self.delay_achieved < self.delay_required + def is_blank(self): # device has no PIN at this point return bool(self.state_flags & PA_IS_BLANK) diff --git a/shared/selftest.py b/shared/selftest.py index ad5f955e..fc780a46 100644 --- a/shared/selftest.py +++ b/shared/selftest.py @@ -34,9 +34,13 @@ def set_genuine(): # - or logged in already as main from pincodes import pa + if pa.is_secondary: + return + if not pa.is_successful(): # assume blank pin during factory selftest pa.setup(b'') + assert not pa.is_delay_needed() # "PIN failures?" if not pa.is_successful(): pa.login() From 4f147b7bbc97071eda34bd218f49b54397124eff Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Tue, 27 Feb 2024 10:45:27 -0500 Subject: [PATCH 46/52] Revert "remove obsolete code" This reverts commit 5173a4e533e5851c51c946e626d7c0c31310d278. --- shared/actions.py | 38 ++++++++++++++++++++++++++++++++++++++ shared/login.py | 44 ++++++++++++++++++++++++++++++++------------ shared/pincodes.py | 2 +- 3 files changed, 71 insertions(+), 13 deletions(-) diff --git a/shared/actions.py b/shared/actions.py index b76156b0..f7803297 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -756,6 +756,44 @@ async def export_seedqr(*a): stash.blank_object(qr) +async def damage_myself(): + # called when it's time to disable ourselves due to various + # features related to duress and so on + # - mk2 cannot do this + # - mk4 doesn't call this, done by bootrom + mode = settings.get('cd_mode', 0) + #['Brick', 'Final PIN', 'Test Mode'] + + if mode == 2: + # test mode, do no damage + return + + from glob import dis + dis.fullscreen("Wait...") + dis.busy_bar(True) + + if mode == 1: + # leave single attempt; careful! + # - always consume one attempt, regardless + todo = max(1, pa.attempts_left - 1) + else: + # brick ourselves, by consuming all PIN attempts + todo = pa.attempts_left + + # do a bunch of failed attempts + pa.setup('hfsp', False) + for i in range(todo): + try: + pa.login() + except: + # expecting EPIN_AUTH_FAIL + pass + + # Try to keep UX responsive? But callgate stuff blocks everything, + # so just go as fast as possible. + + dis.busy_bar(False) + async def version_migration(): # Handle changes between upgrades, and allow downgrades when possible. # - long term we generally cannot delete code from here, because we diff --git a/shared/login.py b/shared/login.py index a5a3c24c..9c6014b6 100644 --- a/shared/login.py +++ b/shared/login.py @@ -2,6 +2,8 @@ # # login.py - UX related to PIN code entry/login. # +# NOTE: Mark3+ hardware does not support secondary wallet concept. +# import pincodes, version, random from glob import dis from display import FontLarge, FontTiny @@ -21,6 +23,7 @@ class LoginUX: self.is_repeat = False self.subtitle = False self.kill_btn = kill_btn + self.offer_second = not version.has_608 self.reset() self.randomize = randomize @@ -33,6 +36,7 @@ class LoginUX: self.pin = '' # just the part we're showing self.pin_prefix = None self.words_ok = False + self.is_secondary = False self.footer = None def show_pin_randomized(self, force_draw): @@ -117,7 +121,7 @@ class LoginUX: dis.show() - def _show_words(self): + def _show_words(self, has_secondary=False): dis.clear() dis.text(None, 0, "Recognize these?" if (not self.is_setting) or self.is_repeat \ @@ -132,7 +136,10 @@ class LoginUX: dis.text(x, y, words[0], FontLarge) dis.text(x, y+18, words[1], FontLarge) - dis.text(None, -1, "X to CANCEL, or OK to CONTINUE", FontTiny) + if self.offer_second: + dis.text(None, -1, "Press (2) for secondary wallet", FontTiny) + else: + dis.text(None, -1, "X to CANCEL, or OK to CONTINUE", FontTiny) dis.busy_bar(False) # includes a dis.show() #dis.show() @@ -180,6 +187,8 @@ class LoginUX: self._show_words() pattern = 'xy' + if self.offer_second: + pattern += '2' if self.kill_btn: pattern += self.kill_btn @@ -194,6 +203,7 @@ class LoginUX: if nxt == 'y' or nxt == '2': self.pin_prefix = self.pin self.pin = '' + self.is_secondary = (nxt == '2') if self.randomize: self.shuffle_keys() @@ -257,14 +267,16 @@ Press OK to continue, X to stop for now. async def try_login(self, bypass_pin=None): while 1: - if not pa.attempts_left: + if version.has_608 and not pa.attempts_left: # tell them it's futile await self.we_are_ewaste(pa.num_fails) self.reset() if pa.num_fails: - self.footer = '%d failures, %d tries left' % (pa.num_fails, pa.attempts_left) + self.footer = '%d failures' % pa.num_fails + if version.has_608: + self.footer += ', %d tries left' % pa.attempts_left pin = await self.interact() @@ -273,7 +285,7 @@ Press OK to continue, X to stop for now. continue dis.fullscreen("Wait...") - pa.setup(pin) + pa.setup(pin, self.is_secondary) if version.has_608 and pa.num_fails > 3: # they are approaching brickage, so warn them each attempt @@ -303,17 +315,24 @@ Press OK to continue, X to stop for now. dis.busy_bar(False) pa.num_fails += 1 - pa.attempts_left -= 1 + if version.has_608: + pa.attempts_left -= 1 - if not pa.attempts_left: - await self.we_are_ewaste(pa.num_fails) - continue - - msg = '%d attempts left' % (pa.attempts_left) + msg = "" nf = '1 failure' if pa.num_fails <= 1 else ('%d failures' % pa.num_fails) + if version.has_608: + if not pa.attempts_left: + await self.we_are_ewaste(pa.num_fails) + continue + + msg += '%d attempts left' % (pa.attempts_left) + else: + msg += '%s' % nf msg += '''\n\nPlease check all digits carefully, and that prefix versus \ -suffix break point is correct.\n\n''' + nf +suffix break point is correct.''' + if version.has_608: + msg += '\n\n' + nf await ux_show_story(msg, title='WRONG PIN') @@ -326,6 +345,7 @@ suffix break point is correct.\n\n''' + nf async def get_new_pin(self, title, story=None, allow_clear=False): # Do UX flow to get new (or change) PIN. Always does the double-entry thing self.is_setting = True + self.offer_second = False if story: # give them background diff --git a/shared/pincodes.py b/shared/pincodes.py index a439390c..2d069fa6 100644 --- a/shared/pincodes.py +++ b/shared/pincodes.py @@ -71,7 +71,7 @@ EPIN_OLD_AUTH_FAIL = const(-113) # We are round-tripping this big structure, partially signed by bootloader. ''' uint32_t magic_value; // = PA_MAGIC_V2 or V1 for older bootroms - int is_secondary; // (bool) primary or secondary OBSOLETE + int is_secondary; // (bool) primary or secondary char pin[MAX_PIN_LEN]; // value being attempted int pin_len; // valid length of pin uint32_t delay_achieved; // so far, how much time wasted? [508a only] From 7af08a4b487f416e3a7a4bd4cb06761e9f6f04d9 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Wed, 28 Feb 2024 11:17:36 -0500 Subject: [PATCH 47/52] correction --- shared/callgate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/callgate.py b/shared/callgate.py index 2fbda61e..36bff005 100644 --- a/shared/callgate.py +++ b/shared/callgate.py @@ -93,7 +93,7 @@ def get_608_rev(): return 'A' if config[7] == 0x3: return 'B' - if config[7] == 0x4: + if config[7] == 0x5: return 'C' return '?' From a0b281a03562333a51721101eba895d5709683ac Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Sun, 18 Feb 2024 17:09:52 +0100 Subject: [PATCH 48/52] testing: allow deprecated create_bdb (ported from edge) --- testing/api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/testing/api.py b/testing/api.py index 9f9b1baf..a18ab18f 100644 --- a/testing/api.py +++ b/testing/api.py @@ -48,6 +48,9 @@ class Bitcoind: self.bitcoind_proc = subprocess.Popen( [ self.bitcoind_path, + # needed for newest master + # TODO legacy wallet will be deprecated in 26 + "-deprecatedrpc=create_bdb", "-regtest", f"-datadir={self.datadir}", "-noprinttoconsole", From cfd26f7d186ac2af4a6f7a213840a2bbb3353c6d Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Fri, 16 Feb 2024 11:04:23 +0100 Subject: [PATCH 49/52] remove excessive newlines from locktime msg --- shared/auth.py | 20 +++++++++++++------- shared/psbt.py | 3 +-- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/shared/auth.py b/shared/auth.py index 1bf0ac56..423eb738 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -692,7 +692,7 @@ class ApproveTransaction(UserAuthorizedAction): fee = self.psbt.calculate_fee() if fee is not None: - msg.write("\nNetwork fee:\n%s %s\n" % self.chain.render_value(fee)) + msg.write("Network fee:\n%s %s\n\n" % self.chain.render_value(fee)) # NEW: show where all the change outputs are going self.output_change_text(msg) @@ -700,13 +700,14 @@ class ApproveTransaction(UserAuthorizedAction): if self.psbt.ux_notes: # currently we only have locktimes in ux_notes - msg.write('\nTX LOCKTIMES\n\n') + msg.write('TX LOCKTIMES\n\n') for label, m in self.psbt.ux_notes: - msg.write('- %s: %s\n\n' % (label, m)) + msg.write('- %s: %s\n' % (label, m)) + msg.write("\n") if self.psbt.warnings: - msg.write('\n---WARNING---\n\n') + msg.write('---WARNING---\n\n') for label, m in self.psbt.warnings: msg.write('- %s: %s\n\n' % (label, m)) @@ -721,7 +722,7 @@ class ApproveTransaction(UserAuthorizedAction): dis.progress_bar_show(1) # finish the Validating... if not hsm_active: - msg.write("\nPress OK to approve and sign transaction. X to abort.") + msg.write("Press OK to approve and sign transaction. X to abort.") ch = await ux_show_story(msg, title="OK TO SEND?") else: ch = await hsm_active.approve_transaction(self.psbt, self.psbt_sha, msg.getvalue()) @@ -862,7 +863,7 @@ class ApproveTransaction(UserAuthorizedAction): total_val = ' '.join(self.chain.render_value(total)) - msg.write("\nChange back:\n%s\n" % total_val) + msg.write("Change back:\n%s\n" % total_val) if len(addrs) == 1: msg.write(' - to address -\n%s\n' % addrs[0]) @@ -871,6 +872,8 @@ class ApproveTransaction(UserAuthorizedAction): for a in addrs: msg.write('%s\n' % a) + msg.write("\n") + def output_summary_text(self, msg): # Produce text report of where their cash is going. This is what # they use to decide if correct transaction is being signed. @@ -881,7 +884,7 @@ class ApproveTransaction(UserAuthorizedAction): # consolidating txn that doesn't change balance of account. msg.write("Consolidating\n%s %s\nwithin wallet.\n\n" % self.chain.render_value(self.psbt.total_value_out)) - msg.write("%d ins - fee\n = %d outs\n" % ( + msg.write("%d ins - fee\n = %d outs\n\n" % ( self.psbt.num_inputs, self.psbt.num_outputs)) return @@ -901,6 +904,7 @@ class ApproveTransaction(UserAuthorizedAction): msg.write(self.render_output(tx_out)) + msg.write("\n") return # Too many to show them all, so @@ -942,6 +946,8 @@ class ApproveTransaction(UserAuthorizedAction): msg.write('%s %s\n' % self.chain.render_value(mtot)) + msg.write("\n") + def sign_transaction(psbt_len, flags=0x0, psbt_sha=None): # transaction (binary) loaded into PSRAM already, checksum checked diff --git a/shared/psbt.py b/shared/psbt.py index e2f2e1a2..6a2ce4a1 100644 --- a/shared/psbt.py +++ b/shared/psbt.py @@ -1309,7 +1309,6 @@ class psbtObject(psbtProxy): msg += "\n\n" for idx, num_blocks in bb: msg += " %d. %d blocks\n" % (idx, num_blocks) - msg += "\n" self.ux_notes.append(("Block height RTL", msg)) @@ -1333,7 +1332,6 @@ class psbtObject(psbtProxy): for idx, seconds in tb: hr = seconds2human_readable(seconds) msg += " %d. %s\n" % (idx, hr) - msg += "\n" self.ux_notes.append(("Time-based RTL", msg)) @@ -1430,6 +1428,7 @@ class psbtObject(psbtProxy): msg += "%d (unix timestamp)" % self.lock_time msg += " (MTP)" # median time past + msg += "\n" self.ux_notes.append(("Abs Locktime", msg)) # create UX for users about tx level relative timelocks (nSequence) From 8fdfdc0f50cd5f1105de32c453b1495c32ef3a78 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Fri, 12 Jan 2024 10:33:23 +0100 Subject: [PATCH 50/52] HW Accelerated AES CTR for BSMS and passphrase saver --- releases/ChangeLog.md | 2 + shared/pwsave.py | 8 +-- shared/utils.py | 4 +- testing/devtest/proof_hw_accel_aes.py | 27 +++++++++ testing/devtest/unit_aes_compat.py | 84 +++++++++++++++++++++++++++ testing/test_unit.py | 4 ++ 6 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 testing/devtest/proof_hw_accel_aes.py create mode 100644 testing/devtest/unit_aes_compat.py diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index 2417519e..65fe91be 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -1,5 +1,7 @@ ## 5.2.3 - 2024-XX-XX +- Enhancement: HW Accelerated AES CTR for passphrase saver, MiscroSD 2FA, + and Tapsigner backup decryption - Bugfix: Saving passphrase on SD Card caused a freeze that required reboot - Bugfix: Properly handle and finalize framing error response - Bugfix: `Brick Me` option for `If Wrong` PIN caused yikes diff --git a/shared/pwsave.py b/shared/pwsave.py index 58ddf8fc..ee8f2aff 100644 --- a/shared/pwsave.py +++ b/shared/pwsave.py @@ -2,7 +2,7 @@ # # pwsave.py - Save bip39 passphrases into encrypted file on MicroSD (if desired) # -import stash, ujson, ngu, pyb, os +import stash, ujson, ngu, pyb, os, aes256ctr from files import CardSlot, CardMissingError, needs_microsd from ux import ux_dramatic_pause, ux_confirm, ux_show_story from utils import xfp2str, problem_file_line @@ -37,7 +37,7 @@ class PassphraseSaver: # Return a list of saved passphrases, or empty list if fail. # Fail silently in all cases. Expect to see lots of noise here. assert self.key - decrypt = ngu.aes.CTR(self.key) + decrypt = aes256ctr.new(self.key) try: fname = self.filename(card) @@ -51,7 +51,7 @@ class PassphraseSaver: async def _save(self, card, data): assert self.key - encrypt = ngu.aes.CTR(self.key) + encrypt = aes256ctr.new(self.key) msg = encrypt.cipher(ujson.dumps(data)) # overwrites whatever already there @@ -324,7 +324,7 @@ class MicroSD2FA(PassphraseSaver): data = dict(nonce=nonce) - encrypt = ngu.aes.CTR(self.key) + encrypt = aes256ctr.new(self.key) msg = encrypt.cipher(ujson.dumps(data)) with open(self.filename(card), 'wb') as fd: diff --git a/shared/utils.py b/shared/utils.py index 21563811..4d3595dc 100644 --- a/shared/utils.py +++ b/shared/utils.py @@ -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, chains, ure, time, aes256ctr from ubinascii import unhexlify as a2b_hex from ubinascii import hexlify as b2a_hex from ubinascii import a2b_base64, b2a_base64 @@ -543,7 +543,7 @@ def chunk_writer(fd, body): def decrypt_tapsigner_backup(backup_key, data): try: backup_key = a2b_hex(backup_key) - decrypt = ngu.aes.CTR(backup_key, bytes(16)) # IV 0 + decrypt = aes256ctr.new(backup_key, bytes(16)) # IV 0 decrypted = decrypt.cipher(data).decode().strip() # format of TAPSIGNER backup is known in advance # extended private key is expected at the beginning of the first line diff --git a/testing/devtest/proof_hw_accel_aes.py b/testing/devtest/proof_hw_accel_aes.py new file mode 100644 index 00000000..024a09db --- /dev/null +++ b/testing/devtest/proof_hw_accel_aes.py @@ -0,0 +1,27 @@ +import utime, ngu, aes256ctr, math + +# Cifra +start = utime.ticks_ms() +for i in range(100): + enc = ngu.aes.CTR(b"a" * 32, "b"*16) + dec = ngu.aes.CTR(b"a" * 32, "b"*16) + em = enc.cipher(b"msg" * i) + dm = dec.cipher(em) + assert dm == b"msg" * i +end = utime.ticks_ms() +cifra_res = utime.ticks_diff(end, start) + + +# aes256ctr +start = utime.ticks_ms() +for i in range(100): + enc = aes256ctr.new(b"a" * 32, "b"*16) + dec = aes256ctr.new(b"a" * 32, "b"*16) + em = enc.cipher(b"msg" * i) + dm = dec.cipher(em) + assert dm == b"msg" * i +end = utime.ticks_ms() +hwa_res = utime.ticks_diff(end, start) + +r = math.ceil(cifra_res / hwa_res) +print("Hardware accelerated AES is approximatelly %dX faster than Cifra AES." % r) \ No newline at end of file diff --git a/testing/devtest/unit_aes_compat.py b/testing/devtest/unit_aes_compat.py new file mode 100644 index 00000000..f6186570 --- /dev/null +++ b/testing/devtest/unit_aes_compat.py @@ -0,0 +1,84 @@ +import ngu, aes256ctr, ujson, ustruct + +key = b"a" * 32 + +bsms_signer = b"""BSMS 1.0 +a54044308ceac9b7 +[eedff89a/48'/0'/0'/2']xpub6EhJvMneoLWAf8cuyLBLQiKiwh89RAmqXEqYeFuaCEHdHwxSRfzLrUxKXEBap7nZSHAYP7Jfq6gZmucotNzpMQ9Sb1nTqerqW8hrtmx6Y6o +Signer 2 key +H/IHW5dMGYsrRdYEz3ux+kKnkWBtxHzfYkREpnYbco38VnMvIxCbDuf7iu6960qDhBLR/RLjlb9UPtLmCMbczDE=""" + +bsms_coord = b"""BSMS 1.0 +wsh(sortedmulti(2,[b7868815/48'/0'/0'/2']xpub6FA5rfxJc94K1kNtxRby1hoHwi7YDyTWwx1KUR3FwskaF6HzCbZMz3zQwGnCqdiFeMTPV3YneTGS2YQPiuNYsSvtggWWMQpEJD4jXU7ZzEh/**,[eedff89a/48'/0'/0'/2']xpub6EhJvMneoLWAf8cuyLBLQiKiwh89RAmqXEqYeFuaCEHdHwxSRfzLrUxKXEBap7nZSHAYP7Jfq6gZmucotNzpMQ9Sb1nTqerqW8hrtmx6Y6o/**)) +/0/*,/1/* +bc1qhs4u273g4azq7kqqpe6vh5wfhasfmrq7nheyzsnq77humd7rwtkqagvakf""" + +pws = [dict(xfp=0x4369050f, pw=pw) for pw in ["about abandon about", "@#$%^&*()", "ksjdfh78$%"]] +pws_ser = ujson.dumps(pws).encode() + +# mimic real data that we use +TEST_CASES = [ + b'Hello World!', + pws_ser, + bsms_coord, + bsms_signer +] + + +def secret_msg_exchange(alice, bob, msg): + e_msg = alice.cipher(msg) + assert bob.cipher(e_msg) == msg + return_msg = msg + b"\x00ACK" + e_msg = bob.cipher(return_msg) + assert alice.cipher(e_msg) == return_msg + + +for i, msg in enumerate(TEST_CASES): + # 16 bytes random IV + # encrypt with Cifra, decrypt with HW accelerated AES + iv = ngu.random.bytes(16) + encrypt = ngu.aes.CTR(key, iv) + decrypt = aes256ctr.new(key, iv) + secret_msg_exchange(encrypt, decrypt, msg) + print("Cifra AES --> HW AES\tIV=0b16\t\tOK") + + # encrypt with HW accelerated AES, decrypt with Cifra + encrypt = aes256ctr.new(key, iv) + decrypt = ngu.aes.CTR(key, iv) + secret_msg_exchange(encrypt, decrypt, msg) + print("HW AES --> Cifra AES\tIV=0b16\t\tOK") + + # empty IV + # encrypt with Cifra, decrypt with HW accelerated AES + encrypt = ngu.aes.CTR(key) + decrypt = aes256ctr.new(key) + secret_msg_exchange(encrypt, decrypt, msg) + print("Cifra AES --> HW AES\tIV=NONE\t\tOK") + + # encrypt with HW accelerated AES, decrypt with Cifra + encrypt = aes256ctr.new(key) + decrypt = ngu.aes.CTR(key) + secret_msg_exchange(encrypt, decrypt, msg) + print("HW AES --> Cifra AES\tIV=NONE\t\tOK") + + +print("RANDOM TEST CASES") +for i in range(10): + key = ngu.random.bytes(32) + iv = ngu.random.bytes(16) + + msg = (key + iv) + if i: + msg = msg * i + + # encrypt with Cifra, decrypt with HW accelerated AES + encrypt = ngu.aes.CTR(key, iv) + decrypt = aes256ctr.new(key, iv) + secret_msg_exchange(encrypt, decrypt, msg) + print("Cifra AES --> HW AES\tIV=0b16\t\tOK") + + # encrypt with HW accelerated AES, decrypt with Cifra + encrypt = aes256ctr.new(key, iv) + decrypt = ngu.aes.CTR(key, iv) + secret_msg_exchange(encrypt, decrypt, msg) + print("HW AES --> Cifra AES\tIV=0b16\t\tOK") diff --git a/testing/test_unit.py b/testing/test_unit.py index 2ff92272..ff832f41 100644 --- a/testing/test_unit.py +++ b/testing/test_unit.py @@ -272,4 +272,8 @@ def test_is_dir(microsd_path, sim_exec): shutil.rmtree(microsd_path("my_dir")) +def test_aes_compatibility(sim_execfile): + res = sim_execfile('devtest/unit_aes_compat.py') + assert res == "" + # EOF From 16f3f7e9fd9a5c114e3de21c1a8f9a7ffa5e3d78 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Fri, 22 Mar 2024 20:32:47 +0100 Subject: [PATCH 51/52] bugfix: backward compatible fix of AFC_BECH32M --- external/ckcc-protocol | 2 +- releases/ChangeLog.md | 1 + shared/address_explorer.py | 5 ++-- shared/usb.py | 4 +++ testing/devtest/unit_af.py | 57 ++++++++++++++++++++++++++++++++++++++ testing/test_unit.py | 5 ++++ 6 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 testing/devtest/unit_af.py diff --git a/external/ckcc-protocol b/external/ckcc-protocol index f924f6d3..a6d901f9 160000 --- a/external/ckcc-protocol +++ b/external/ckcc-protocol @@ -1 +1 @@ -Subproject commit f924f6d35ca0a6804b9e25d476cb53ae2f8ae8d6 +Subproject commit a6d901f9fca50755835eca895586ca74d0ca81ed diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index 65fe91be..dbd65167 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -9,6 +9,7 @@ wallet name - Bugfix: Very obscure bug in low level code could cause txid to be miscalculated if all the conditions occured just right +- Bugfix: AFC_BECH32M incorrectly sets AFC_WRAPPED and AFC_BECH32 ## 5.2.2 - 2023-12-21 diff --git a/shared/address_explorer.py b/shared/address_explorer.py index 9fa80192..d629ffa2 100644 --- a/shared/address_explorer.py +++ b/shared/address_explorer.py @@ -7,7 +7,7 @@ import chains, stash from ux import ux_show_story, the_ux, ux_enter_bip32_index from menu import MenuSystem, MenuItem -from public_constants import AFC_BECH32, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH +from public_constants import AFC_BECH32, AFC_BECH32M, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH from multisig import MultisigWallet from uasyncio import sleep_ms from uhashlib import sha256 @@ -344,7 +344,8 @@ Press (3) if you really understand and accept these risks. continue from ux import show_qr_codes - await show_qr_codes(addrs, bool(addr_fmt & AFC_BECH32), start) + is_alnum = bool(addr_fmt & (AFC_BECH32|AFC_BECH32M)) + await show_qr_codes(addrs, is_alnum, start) continue elif ch == '3' and NFC: diff --git a/shared/usb.py b/shared/usb.py index 7d4383a6..7fad5e43 100644 --- a/shared/usb.py +++ b/shared/usb.py @@ -470,6 +470,10 @@ class USBHandler: from auth import usb_show_address addr_fmt, = unpack_from(' Date: Tue, 2 Apr 2024 12:13:54 -0400 Subject: [PATCH 52/52] Add Q1 releases to master --- releases/signatures.txt | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/releases/signatures.txt b/releases/signatures.txt index dc0ac668..6bace430 100644 --- a/releases/signatures.txt +++ b/releases/signatures.txt @@ -4,7 +4,23 @@ Hash: SHA256 715a3ec7a91d2366788b14a243e7343de875714b647d1e2bc906ecc5b752d8d9 README.md 8f71336a78573ccbd19b782e4f6e5930a8f944517884fe02d26eb0f38bf5c3ac Mk3ChangeLog.md a0c4d0ac3881a36704f0b620a13c72704531f656ee29368d5aac87dc5f21c7a1 History.md -bf86232f94f40a3fef1e0e5e8dd1f184f7e2782606001a121f71a911c1eab95d ChangeLog.md +6d81c0f7b93a8bc7db942225c08b1e54a7c2e1decf1a7a9179540a875c16da4c ChangeLog.md +101f336310b9b460d717d91d2572ea9e9ef7ac3edbdaf132c7c3aa46bb89050a 2024-04-02T1416-v1.1.0Q-q1-coldcard.dfu +5d034bc6b1abec49a067a90766bdb769faf9a1b52b2c9b7e541d32484cf783fc 2024-04-02T1416-v1.1.0Q-q1-coldcard-factory.dfu +6ea843a56e87d7d811d90be6bfa4703794bbc8318d9709e88ada05740e03b12d 2024-03-14T1419-v1.0.1Q-q1-coldcard.dfu +f53c79c64f02dd1e860a8d32f9319edd279485d97f07815b2a1eb180a1305459 2024-03-14T1419-v1.0.1Q-q1-coldcard-factory.dfu +122e6d757eb5a8ce073d98a85851f376adec97856336c5a8f05b953b5c87a533 2024-03-10T1537-v1.0.0Q-q1-coldcard.dfu +ae04aaac47f07e10143c75b5c772b54739830214c8234356d003137897f3f4f4 2024-03-10T1537-v1.0.0Q-q1-coldcard-factory.dfu +6aaa9d5bf1726fe4d4a4834010d9b9b6525e8592bb97945cd08cc728fc884068 2024-03-02T1750-v0.0.8Q-q1-coldcard.dfu +a0cd556693fae5b8b03f2a498c0abb1e6d747f91a92bd8f2559a676f8707d840 2024-03-02T1750-v0.0.8Q-q1-coldcard-factory.dfu +18fe081d84a950e1fddb2151ad50917697dfc218cd68e2e359229b0bdadbff37 2024-02-26T1442-v0.0.7Q-q1-coldcard.dfu +e4f4fe89cf3743d794568fd5b32b14551966139e9199602ea10468f925fab1cf 2024-02-26T1442-v0.0.7Q-q1-coldcard-factory.dfu +2dc7a27f43958f2de9851f221183c94258ac915ae43d997b39b644e7b9daff8f 2024-02-22T1423-v0.0.6Q-q1-coldcard.dfu +1e4f4d4c04835d78fcc4857d3264034a56dccf594e307d7408d7c4cdcdb0a926 2024-02-22T1423-v0.0.6Q-q1-coldcard-factory.dfu +d51573c72d8958ea35357d4e0a36ce6aaa2d05924577efb219e2cc189be63f08 2024-02-16T1635-v0.0.5Q-q1-coldcard.dfu +55f4ef9c3ae116f50db938acfc3a4b09717965f82cf6de8cc7385f68cd66d285 2024-02-16T1635-v0.0.5Q-q1-coldcard-factory.dfu +8fd1ced0d5e0338d845f6d5ec5ab069a5143cceade02d4f17e86b7d182b489eb 2024-02-15T1843-v0.0.4Q-q1-coldcard.dfu +43fac084727b0e69bae7fc040a62854673fd585dc2435d93bf146c80762e41cf 2024-02-15T1843-v0.0.4Q-q1-coldcard-factory.dfu 3064bf7f1a039e7cd5c1a13c6aff8cc4338e52ef2177abbdca4b196955f9e434 2024-02-08T2005-v0.0.3Q-q1-coldcard.dfu 788e7a1b182f920016617411b875fa7095ae007c6a53fc476afb1c93f0eed1c9 2024-02-08T2005-v0.0.3Q-q1-coldcard-factory.dfu a9d0b416c3cb4f122f2826283fce82bbc5fe4464817b601a3a5787b1f8aaba20 2024-01-18T1507-v6.2.2X-mk4-coldcard.dfu @@ -54,12 +70,12 @@ f05bc8dfed047c0c0abe5ed60621d2d14899b10717221c4af0942d96a1754f33 2021-03-29T192 bea27f263b524a66b3ed0a58c16805e98be0d7c3db20c2f7aab3238f2c6a6995 2019-12-19T1623-v3.0.6-coldcard.dfu -----BEGIN PGP SIGNATURE----- -iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmXFQWEACgkQo6MbrVoq -WxDr/AgAmDIvMg5pdrqoMP+qiS3Gd2UgwBRDyU1IkTkwP73f50dksCROz2G74Rh5 -+A0YYcKiB2FjZKBPGFwfwBO8fiuqeNoYIZtXzObWJJk1gHEFmRSKFnmQ0qCoc1l1 -vl2adlOIMG6D7GB7Ilxqb0/JvbKMSTSY6fLiAy2UZmL8cfIneMvm1HrI+jmuuZze -lKraKUksHZV4vwXvhr6z0B0L/lqMW5ZMfIAcYulE7eQmgY4Y8jM4von3v/YsD4Od -e8J2JaSXF8xwZQMob6ILVoq2AEvH5AxtAtgilYK+IuDdCashqOcEW2wgYTxNFKYu -JkNveZGe67gsZfbWDcxN3c8SEYejww== -=OoZB +iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmYMLi8ACgkQo6MbrVoq +WxBSJgf+JoMeVO5n/Tw0+NsTHfPscFLO7kgJFQghyD5nxc7+bb7if8W3V+mnKyYQ +QzQRjUXWK8pjHKgL+6vSCt5qIMiX5TSy60/PChc1QA6Cg+C7XBOAkjzveXOetbXV +hmfcOOVCkgQhDwoDXZbF3LB0i3TS5GSPqUgZKwXVJO3aU9iW56eEZorzPzW064UW +KRRWK0v2lPlmn/zzrpTUWEVzDsuF5VrKGk3UdHFFSDHPNgd2B8UFVdVnuX7YWS18 +9MBjeauFQFYm9nIOT+EgNsWNhJZ+TM9U0M+rpNnWtHZIC1obta03qBSLSnxrq9b1 +NtzdHCSTLszXzusJNKUYQKDgpSmQYQ== +=KBSF -----END PGP SIGNATURE-----