From be614dab92dd75c85f373d47b36f172529c12b2c Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Tue, 14 Apr 2026 17:04:20 +0200 Subject: [PATCH] bugfix: Delta Mode Trick PIN restore from backup --- releases/Next-ChangeLog.md | 2 +- shared/trick_pins.py | 16 ++--- testing/clone_tests.py | 101 +++++++++++++++++++++++++++++- testing/conftest.py | 10 ++- testing/core_fixtures.py | 114 +++++++++++++++++++++++++++++++++- testing/test_ux.py | 122 +++---------------------------------- 6 files changed, 235 insertions(+), 130 deletions(-) diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md index 35ee9a33..b829d2b7 100644 --- a/releases/Next-ChangeLog.md +++ b/releases/Next-ChangeLog.md @@ -4,7 +4,7 @@ This lists the new changes that have not yet been published in a normal release. # Shared Improvements - Both Mk and Q -- tbd +- Bugfix: Delta Mode Trick PIN was never restored from backup # Mk Specific Changes diff --git a/shared/trick_pins.py b/shared/trick_pins.py index 04769625..90c3b20d 100644 --- a/shared/trick_pins.py +++ b/shared/trick_pins.py @@ -211,14 +211,14 @@ class TrickPinMgmt: def update_slot(self, pin, new=False, new_pin=None, tc_flags=None, tc_arg=None, secret=None): # create or update a trick pin # - doesn't support wallet to no-wallet transitions - ''' - >>> from pincodes import pa; pa.setup(b'12-12'); pa.login(); from trick_pins import * - ''' + # + # from pincodes import pa; pa.setup(b'12-12'); pa.login(); from trick_pins import * + # assert isinstance(pin, bytes) b, slot = self.get_by_pin(pin) if not slot: - if not new: raise KeyError("wrong pin") + assert new, "wrong pin" # Making a new entry b, slot = make_slot() @@ -398,17 +398,17 @@ class TrickPinMgmt: continue if flags & TC_DELTA_MODE: - prob = validate_delta_pin(true_pin, pin) + prob, _ = validate_delta_pin(true_pin, pin) if prob: # just forget it, no UI here to report issue - continue + continue try: # might need to construct a BIP-85 or XPRV secret to match path, new_secret = construct_duress_secret(flags, arg) - b, slot = tp.update_slot(pin.encode(), new=True, - tc_flags=flags, tc_arg=arg, secret=new_secret) + tp.update_slot(pin.encode(), new=True, secret=new_secret, + tc_flags=flags, tc_arg=arg) except: pass @staticmethod diff --git a/testing/clone_tests.py b/testing/clone_tests.py index d4b1e4e5..ae5b017c 100644 --- a/testing/clone_tests.py +++ b/testing/clone_tests.py @@ -2,10 +2,12 @@ # import pytest, time, pdb, itertools from charcodes import KEY_ENTER -from core_fixtures import _pick_menu_item, _cap_story, _press_select -from core_fixtures import _need_keypress, _cap_menu, _sim_exec +from core_fixtures import _pick_menu_item, _cap_story, _press_select, _word_menu_entry +from core_fixtures import _need_keypress, _cap_menu, _sim_exec, _pass_word_quiz from run_sim_tests import ColdcardSimulator, clean_sim_data +from ckcc_protocol.cli import wait_and_download from ckcc_protocol.client import ColdcardDevice +from ckcc_protocol.protocol import CCProtocolPacker def _clone(source, target): @@ -123,4 +125,99 @@ def test_clone(source, target): _clone(source, target) time.sleep(1) + +def test_backup_restore_delta_pin(): + # SOURCE + # clone with multisig wallet + clean_sim_data() # remove all from previous + sim_source = ColdcardSimulator(args=["--ms", "--p2wsh", "--set", "nfc=1", "--set", "vidsk=1"], + segregate=True) # in /tmp/cc-simulators + sim_source.start(start_wait=6) + device_source = ColdcardDevice(is_simulator=True, sn=sim_source.socket) + _pick_menu_item(device_source, False, "Settings") + time.sleep(.1) + _pick_menu_item(device_source, False, "Login Settings") + time.sleep(.1) + _pick_menu_item(device_source, False, "Trick PINs") + time.sleep(.1) + _pick_menu_item(device_source, False, "Add New Trick") + time.sleep(.1) + + # twice, first select, then verify + for _ in range(2): + pin = "11-11" + pre, suff = pin.split("-") + for ch in pre: + _need_keypress(device_source, ch) + time.sleep(.1) + _press_select(device_source, False) + + time.sleep(.2) + + for ch in suff: + _need_keypress(device_source, ch) + time.sleep(.1) + _press_select(device_source, False) + + time.sleep(.2) + _pick_menu_item(device_source, False, "Delta Mode") + time.sleep(.1) + title, story = _cap_story(device_source) + assert "trick PIN must be same length as true PIN and differ only in final 4 positions" in story + _press_select(device_source, False) + time.sleep(.1) + _press_select(device_source, False) + time.sleep(.1) + m = _cap_menu(device_source) + assert "11-11" in m[1] + + ok = device_source.send_recv(CCProtocolPacker.start_backup()) + assert ok is None + time.sleep(1) + title, story = _cap_story(device_source) + assert "backup file password" in story + word_list = [item.split()[-1] for item in story.split("\n")[1:-4]] + assert len(word_list) == 12 + _pass_word_quiz(device_source, False, word_list) + _press_select(device_source, False) # bkpw + result, chk = wait_and_download(device_source, CCProtocolPacker.get_backup_file(), 0) + sim_source.stop() + + + # TARGET Q (empty) + sim_target = ColdcardSimulator(args=["--q1", "-l"]) + sim_target.start(start_wait=6) + device_target = ColdcardDevice(is_simulator=True) + + name = "backup-delta.7z" + path = f"../unix/work/MicroSD/{name}" + with open(path, "wb") as f: + f.write(result) + + _pick_menu_item(device_target, True, "Import Existing") + _pick_menu_item(device_target, True, "Restore Backup") + _pick_menu_item(device_target, True, name) + time.sleep(.1) + + _word_menu_entry(device_target, True, word_list, has_checksum=False) + _press_select(device_target, True) # allow backup restore + time.sleep(.1) + _press_select(device_target, True) # best security practices config + time.sleep(.1) + _press_select(device_target, True) # success + + sim_target.stop() + time.sleep(1) + sim_target = ColdcardSimulator(args=["--q1"]) + sim_target.start(start_wait=6) + device_target = ColdcardDevice(is_simulator=True, sn=sim_target.socket) + _pick_menu_item(device_target, True, "Settings") + time.sleep(.1) + _pick_menu_item(device_target, True, "Login Settings") + time.sleep(.1) + _pick_menu_item(device_target, True, "Trick PINs") + time.sleep(.1) + m = _cap_menu(device_target) + assert "11-11" in m[1] + # EOF \ No newline at end of file diff --git a/testing/conftest.py b/testing/conftest.py index 6bc87375..533d1c41 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -15,6 +15,7 @@ from constants import * from charcodes import * from core_fixtures import _need_keypress, _sim_exec, _cap_story, _cap_menu, _cap_screen, _sim_eval from core_fixtures import _press_select, _pick_menu_item, _enter_complex, _dev_hw_label +from core_fixtures import _do_keypresses from txn import render_address from bbqr import split_qrs @@ -207,14 +208,11 @@ def enter_pin(enter_number, press_select, cap_screen, is_q1): @pytest.fixture -def do_keypresses(need_keypress): +def do_keypresses(dev): # do a series of keypresses, any kind - def doit(value): - for ch in value: - need_keypress(ch) + f = functools.partial(_do_keypresses, dev) + return f - return doit - @pytest.fixture def enter_text(need_keypress, is_q1): diff --git a/testing/core_fixtures.py b/testing/core_fixtures.py index 1964bbb0..a2928704 100644 --- a/testing/core_fixtures.py +++ b/testing/core_fixtures.py @@ -7,7 +7,7 @@ # Below functions are injected with proper scoped `device` in conftest.py # using funtools.partial. # -import time +import time, re from charcodes import * from ckcc_protocol.client import CCProtocolPacker @@ -149,4 +149,116 @@ def _enter_complex(device, is_Q, target, apply=False, b39pass=True): if apply: _pick_menu_item(device, is_Q, "APPLY") + +def _pass_word_quiz(device, is_Q, words, prefix='', preload=None): + if not preload: + _press_select(device, is_Q) + time.sleep(.01) + + count = 0 + last_title = None + while 1: + title, body = preload or _cap_story(device) + preload = None + + if not title.startswith('Word ' + prefix): break + assert title.endswith(' is?') + assert not last_title or last_title != title, "gave wrong ans?" + + wn = int(title.split()[1][len(prefix):]) + assert 1 <= wn <= len(words) + wn -= 1 + + ans = [w[3:].strip() for w in body.split('\n') if w and w[2] == ':'] + assert len(ans) == 3 + + correct = ans.index(words[wn]) + assert 0 <= correct < 3 + + # print("Pick %d: %s" % (correct, ans[correct])) + + _need_keypress(device, chr(49 + correct)) + time.sleep(.1) + count += 1 + + last_title = title + + return count, title, body + + +def _do_keypresses(device, value): + for ch in value: + _need_keypress(device, ch) + +def _word_menu_entry(device, is_Q, words, has_checksum=True, q_accept=True): + if is_Q: + # easier for us on Q, but have to anticipate the autocomplete + for n, w in enumerate(words, start=1): + _do_keypresses(device, w[0:2]) + time.sleep(0.1) + if 'Next key' in _cap_screen(device): + _do_keypresses(device, w[2]) + time.sleep(.01) + + if 'Next key' in _cap_screen(device): + if len(w) > 3: + _do_keypresses(device, w[3]) + else: + _do_keypresses(device, KEY_DOWN) + time.sleep(.01) + + pat = rf'{n}:\s?{w}' + for x in range(10): + if re.search(pat, _cap_screen(device)): + break + time.sleep(0.02) + else: + raise RuntimeError('timeout') + + if len(words) == 23: + _do_keypresses(device, KEY_DOWN) + time.sleep(.03) + cap_scr = _cap_screen(device) + while 'Next key' in cap_scr: + target = cap_scr.split("\n")[-1].replace("Next key: ", "") + # picks first choice!? + _do_keypresses(device, target[0]) + time.sleep(.03) + cap_scr = _cap_screen(device) + else: + cap_scr = _cap_screen(device) + + if has_checksum: + assert 'Valid words' in cap_scr + else: + assert 'Press ENTER if all done' in cap_scr + + if q_accept: + _do_keypresses(device, '\r') + return + + # do the massive drilling-down to pick a specific pass phrase + assert len(words) in {1, 12, 18, 23, 24} + + for word in words: + while 1: + menu = _cap_menu(device) + which = None + for m in menu: + if '-' not in m: + if m == word: + which = m + break + else: + assert m[-1] == '-' + if m == word[0:len(m)-1]+'-': + which = m + break + + assert which, "cant find: " + word + + _pick_menu_item(device, is_Q, which) + if '-' not in which: + break + # EOF diff --git a/testing/test_ux.py b/testing/test_ux.py index fb9d564c..cbd4744e 100644 --- a/testing/test_ux.py +++ b/testing/test_ux.py @@ -1,11 +1,12 @@ # (c) Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC. # -import pytest, time, os, re, hashlib, shutil -from helpers import xfp2str, prandom, addr_from_display_format -from charcodes import KEY_DOWN, KEY_QR, KEY_NFC, KEY_DELETE, KEY_UP +import pytest, time, os, re, hashlib, shutil, functools +from helpers import xfp2str, prandom +from charcodes import KEY_QR, KEY_NFC, KEY_DELETE from constants import AF_CLASSIC, simulator_fixed_words, simulator_fixed_xfp from mnemonic import Mnemonic from bip32 import BIP32Node +from core_fixtures import _pass_word_quiz, _word_menu_entry mnem = Mnemonic('english') wordlist = mnem.wordlist @@ -97,117 +98,14 @@ def test_home_menu(cap_menu, cap_story, cap_screen, need_keypress, reset_seed_wo press_cancel() @pytest.fixture -def word_menu_entry(cap_menu, pick_menu_item, is_q1, do_keypresses, cap_screen): - def doit(words, has_checksum=True, q_accept=True): - if is_q1: - # easier for us on Q, but have to anticipate the autocomplete - for n, w in enumerate(words, start=1): - do_keypresses(w[0:2]) - time.sleep(0.05) - if 'Next key' in cap_screen(): - do_keypresses(w[2]) - time.sleep(.01) - if 'Next key' in cap_screen(): - if len(w) > 3: - do_keypresses(w[3]) - else: - do_keypresses(KEY_DOWN) - time.sleep(.01) - - pat = rf'{n}:\s?{w}' - for x in range(10): - if re.search(pat, cap_screen()): - break - time.sleep(0.02) - else: - raise RuntimeError('timeout') - - if len(words) == 23: - do_keypresses(KEY_DOWN) - time.sleep(.03) - cap_scr = cap_screen() - while 'Next key' in cap_scr: - target = cap_scr.split("\n")[-1].replace("Next key: ", "") - # picks first choice!? - do_keypresses(target[0]) - time.sleep(.03) - cap_scr = cap_screen() - else: - cap_scr = cap_screen() - - if has_checksum: - assert 'Valid words' in cap_scr - else: - assert 'Press ENTER if all done' in cap_scr - - if q_accept: - do_keypresses('\r') - return - - # do the massive drilling-down to pick a specific pass phrase - assert len(words) in {1, 12, 18, 23, 24} - - for word in words: - while 1: - menu = cap_menu() - which = None - for m in menu: - if '-' not in m: - if m == word: - which = m - break - else: - assert m[-1] == '-' - if m == word[0:len(m)-1]+'-': - which = m - break - - assert which, "cant find: " + word - - pick_menu_item(which) - if '-' not in which: - break - - return doit +def word_menu_entry(dev, cap_menu, pick_menu_item, is_q1, do_keypresses, cap_screen): + f = functools.partial(_word_menu_entry, dev, is_q1) + return f @pytest.fixture -def pass_word_quiz(need_keypress, cap_story, press_select): - def doit(words, prefix='', preload=None): - if not preload: - press_select() - time.sleep(.01) - - count = 0 - last_title = None - while 1: - title, body = preload or cap_story() - preload = None - - if not title.startswith('Word '+prefix): break - assert title.endswith(' is?') - assert not last_title or last_title != title, "gave wrong ans?" - - wn = int(title.split()[1][len(prefix):]) - assert 1 <= wn <= len(words) - wn -= 1 - - ans = [w[3:].strip() for w in body.split('\n') if w and w[2] == ':'] - assert len(ans) == 3 - - correct = ans.index(words[wn]) - assert 0 <= correct < 3 - - #print("Pick %d: %s" % (correct, ans[correct])) - - need_keypress(chr(49 + correct)) - time.sleep(.1) - count += 1 - - last_title = title - - return count, title, body - - return doit +def pass_word_quiz(dev, need_keypress, cap_story, press_select, is_q1): + f = functools.partial(_pass_word_quiz, dev, is_q1) + return f @pytest.mark.qrcode