bugfix: Delta Mode Trick PIN restore from backup
What changed, and why it matters
This commit fixes a bug where a special security feature called 'Delta Mode Trick PIN' was not being restored when a user restored their Coldcard wallet from a backup. A Trick PIN is a decoy PIN that can do things like show a fake wallet or wipe the device. Delta Mode is a type of Trick PIN that must be very similar to the real PIN. Because of the bug, after restoring from backup, this protection would silently disappear, leaving users who relied on it less protected than they expected. The fix changes the backup-restore code so Delta Mode Trick PINs are correctly recreated, and adds an automated test to check this.
Users who rely on Delta Mode Trick PINs should upgrade to a firmware release containing this fix and, after upgrading, verify that their Delta Mode Trick PINs are still configured; if not, re-create them and take a fresh backup. Developers should review other trick-PIN restore paths for similar silent-failure patterns and consider replacing bare except: pass with targeted exception handling and logging.
Security signals we found
Functional bug in security feature (Trick PIN restore)
Silent failure due to bare exception handler
Mismatch between function return value and caller (tuple unpack)
Backup/restore integrity issue
New regression test added for the fixed behavior
Evidence from the diff
In shared/trick_pins.py, the restore logic iterates over saved trick PIN slots and reconstructs them. For TC_DELTA_MODE entries, validate_delta_pin() now returns a tuple (prob, _) instead of just prob, so the caller was updated to unpack it. More importantly, the code that recreates the slot during restore was previously not creating Delta Mode entries correctly because update_slot() was called with positional/keyword arguments in an order that did not match the expected signature, and the function silently caught exceptions with a bare except: pass. The patch reorders the call to update_slot(pin.encode(), new=True, secret=new_secret, tc_flags=flags, tc_arg=arg) and removes the silent exception swallowing in the relevant path. A new simulator test, test_backup_restore_delta_pin, creates a Delta Mode trick PIN, backs up, restores to a Q1 simulator, and asserts the trick PIN is present after restore.
Changed components
shared/trick_pins.pyColdcard backup restore flowDelta Mode Trick PIN featureMk and Q firmware variantsInspect captured patch +235 / −130
diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md
index 35ee9a3..b829d2b 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 0476962..90c3b20 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 d4b1e4e..ae5b017 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 6bc8737..533d1c4 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 1964bbb..a292870 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 fb9d564..cbd4744 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
Why this scored 48/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.