bugfix: Delta Mode Trick PIN restore from backup

This commit is contained in:
scgbckbone 2026-04-14 17:04:20 +02:00 committed by doc-hex
parent 02bd428786
commit be614dab92
6 changed files with 235 additions and 130 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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