What changed, and why it matters
This commit mainly adds automated tests for a COLDCARD feature called Single-Signer Spending Policy (SSSP). It also makes a small code change in the login flow: when unlocking a spending policy, the device no longer loops asking for the main PIN over and over until secrets are loaded. Instead it asks once and proceeds. The change is framed as cleanup and test support; there is no clear security bug being fixed, but removing a retry loop could matter if the loop was hiding a logic issue.
Review whether removing the while-loop and the pa.has_secrets() check in start_login_sequence() is safe. Ensure that block_until_login() and downstream code guarantee that a valid secret is loaded before any signing or policy operation, so that a bypass PIN followed by an invalid/cancelled main PIN cannot leave the device in an unintended state. Run the new SSSP tests and consider adding a negative test for cancelling the main PIN unlock.
Security signals we found
Login/authentication flow modified
Retry loop removed from PIN unlock path
No explicit secret-material validation after unlock prompt
Tests added for bypass PIN, countdown, and trick PIN interactions
Evidence from the diff
The diff adds ~300 lines of pytest-based simulator tests in testing/login_settings_tests.py covering SSSP bypass PIN behavior, login countdown interaction, and trick PINs. A helper _press_cancel is added to testing/core_fixtures.py. In shared/actions.py, start_login_sequence() is refactored: the while-loop that repeatedly prompted for the main PIN after a bypass/unlock and checked pa.has_secrets() is removed. Now it shows the story once, resets the PIN state, and calls block_until_login() once. The comment is updated to reflect that the code no longer verifies secrets were loaded. This is a behavioral change, not purely a test addition.
Changed components
shared/actions.py:start_login_sequencetesting/login_settings_tests.pytesting/core_fixtures.pyInspect captured patch +298 / −10
diff --git a/shared/actions.py b/shared/actions.py
index 893f11a..f64fe18 100644
--- a/shared/actions.py
+++ b/shared/actions.py
@@ -814,16 +814,12 @@ async def start_login_sequence():
sp_unlock = tp.was_sp_unlock()
if sp_unlock:
# Trying to unlock spending policy: ask for main PIN next.
- while 1:
- await ux_show_story("Spending Policy Unlock: Please provide Main PIN next.")
-
- pa.reset()
- await block_until_login()
+ await ux_show_story("Spending Policy Unlock: Please provide Main PIN next.")
+ pa.reset()
+ await block_until_login()
- # we don't really know if that was the Main PIN (could easily be the bypass
- # PIN again) and if it's a duress wallet, that's cool... so just be
- # sure we got some secret material to work/play with
- if pa.has_secrets(): break
+ # we don't really know if that was the Main PIN (could easily be the bypass
+ # PIN again) and if it's a duress wallet, that's cool...
# Do we need to do countdown delay? (real or otherwise)
# - wiping has already occured if that was selected by trick details
diff --git a/testing/core_fixtures.py b/testing/core_fixtures.py
index e8381a6..38e88fc 100644
--- a/testing/core_fixtures.py
+++ b/testing/core_fixtures.py
@@ -44,6 +44,10 @@ def _press_select(device, is_Q, timeout=None):
btn = KEY_ENTER if is_Q else "y"
_need_keypress(device, btn, timeout=timeout)
+def _press_cancel(device, is_Q, timeout=None):
+ btn = KEY_CANCEL if is_Q else "x"
+ _need_keypress(device, btn, timeout=timeout)
+
def _dev_hw_label(device):
# gets a short string that labels product: mk4 / q1, etc
v = device.send_recv(CCProtocolPacker.version()).split()
diff --git a/testing/login_settings_tests.py b/testing/login_settings_tests.py
index 341b93f..7cef366 100644
--- a/testing/login_settings_tests.py
+++ b/testing/login_settings_tests.py
@@ -10,7 +10,7 @@
#
import pytest, time, pdb
from core_fixtures import _pick_menu_item, _cap_menu, _cap_story, _cap_screen
-from core_fixtures import _need_keypress, _enter_complex, _press_select
+from core_fixtures import _need_keypress, _enter_complex, _press_select, _press_cancel
from ckcc_protocol.client import ColdcardDevice
from run_sim_tests import ColdcardSimulator, clean_sim_data
@@ -530,5 +530,293 @@ def test_calc_login(request):
assert "Ready To Sign" in m
sim.stop()
+@pytest.mark.parametrize("word_check", [True, False])
+@pytest.mark.parametrize("randomize", [True, False])
+def test_sssp_bypass_pin(request, word_check, randomize):
+ main_pin = "22-22"
+ bypass_pin = "111-111"
+ is_Q = request.config.getoption('--Q')
+ clean_sim_data() # remove all from previous
+ sim = ColdcardSimulator(args=["--q1"] if is_Q else [])
+ sim.start(start_wait=6)
+ device = ColdcardDevice(is_simulator=True)
+
+ if randomize:
+ _pick_menu_item(device, is_Q, "Settings")
+ _pick_menu_item(device, is_Q, "Login Settings")
+ _set_scramble_pin_entry(device, is_Q)
+ time.sleep(1)
+
+ for _ in range(2):
+ _press_cancel(device, is_Q)
+
+ time.sleep(.1)
+
+ _pick_menu_item(device, is_Q, "Advanced/Tools")
+ _pick_menu_item(device, is_Q, "Spending Policy")
+ _pick_menu_item(device, is_Q, "Single-Signer")
+ _press_select(device, is_Q) # confirm story
+ # now create bypass PIN
+ # 1st entry
+ _login(device, is_Q, bypass_pin)
+ # 2nd confirmation entry
+ _login(device, is_Q, bypass_pin)
+
+ if word_check:
+ _pick_menu_item(device, is_Q, "Word Check")
+ title, story = _cap_story(device)
+ assert "Enable?" in story
+ assert "must provide the first and last seed words" in story
+ _press_select(device, is_Q)
+
+ time.sleep(2) # needed here to actually save to settings
+ sim.stop()
+
+ sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", main_pin, "--early-usb"])
+ sim.start(start_wait=6)
+ device = ColdcardDevice(is_simulator=True)
+
+ # first login, but with main PIN, ends up in SSSP
+ _login(device, is_Q, main_pin, scrambled=randomize)
+ time.sleep(.1)
+ menu = _cap_menu(device)
+ assert "Settings" not in menu
+
+ sim.stop()
+
+ sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", main_pin, "--early-usb"])
+ sim.start(start_wait=6)
+ device = ColdcardDevice(is_simulator=True)
+
+ # now bypass PIN, normal operation
+ time.sleep(.1)
+ _login(device, is_Q, bypass_pin, scrambled=randomize)
+ time.sleep(.1)
+ title, story = _cap_story(device)
+ assert "Spending Policy Unlock" in story
+ _press_select(device, is_Q)
+ time.sleep(.1)
+ _login(device, is_Q, main_pin, scrambled=randomize)
+ time.sleep(.1)
+
+ if word_check:
+ # first do incorrect words
+ if is_Q:
+ assert "First and Last Seed Word" in _cap_screen(device)
+ # just because of auto-fil feature
+ _enter_complex(device, is_Q, "wif")
+ _enter_complex(device, is_Q, "kic")
+ else:
+ # this is not a text input field - but word nest menu
+ # wife -> 3xUP, 3xDOWN, DOWN
+ for _ in range(3):
+ _need_keypress(device, "5")
+ _press_select(device, is_Q)
+ time.sleep(.1)
+
+ for _ in range(3):
+ _need_keypress(device, "8")
+ _press_select(device, is_Q)
+ time.sleep(.1)
+
+ _need_keypress(device, "8")
+ _press_select(device, is_Q)
+
+ time.sleep(.1)
+ # abandon -> 3xOK
+ for _ in range(3):
+ _press_select(device, is_Q)
+
+ time.sleep(.1)
+ title, story = _cap_story(device)
+ assert "Sorry, those words are incorrect" in story
+ _press_select(device, is_Q)
+ time.sleep(.1)
+ # now insert correct words
+ if is_Q:
+ # just because of auto-fil feature
+ _enter_complex(device, is_Q, "wif")
+ _enter_complex(device, is_Q, "clar")
+ else:
+ # wife -> 3xUP, 3xDOWN, DOWN
+ for _ in range(3):
+ _need_keypress(device, "5")
+ _press_select(device, is_Q)
+ time.sleep(.1)
+
+ for _ in range(3):
+ _need_keypress(device, "8")
+ _press_select(device, is_Q)
+ time.sleep(.1)
+
+ _need_keypress(device, "8")
+ _press_select(device, is_Q)
+
+ # clarify 2xDOWN, 4xDOWN, 2xDOWN
+ for _ in range(2):
+ _need_keypress(device, "8")
+ _press_select(device, is_Q)
+ time.sleep(.1)
+
+ for _ in range(4):
+ _need_keypress(device, "8")
+ _press_select(device, is_Q)
+ time.sleep(.1)
+
+ for _ in range(2):
+ _need_keypress(device, "8")
+ _press_select(device, is_Q)
+ time.sleep(.1)
+
+ menu = _cap_menu(device)
+ assert "Settings" in menu # not in SSSP
+
+ sim.stop()
+
+
+def test_sssp_login_countdown(request):
+ bypass_pin = "236-156"
+ is_Q = request.config.getoption('--Q')
+ clean_sim_data() # remove all from previous
+ sim = ColdcardSimulator(args=["--q1"] if is_Q else [])
+ sim.start(start_wait=6)
+ device = ColdcardDevice(is_simulator=True)
+
+ _pick_menu_item(device, is_Q, "Settings")
+ _pick_menu_item(device, is_Q, "Login Settings")
+ _set_login_countdown(device, is_Q, " 5 minutes")
+
+ time.sleep(.2)
+ for _ in range(2): # go back
+ _press_cancel(device, is_Q)
+
+ time.sleep(.1)
+ _pick_menu_item(device, is_Q, "Advanced/Tools")
+ _pick_menu_item(device, is_Q, "Spending Policy")
+ _pick_menu_item(device, is_Q, "Single-Signer")
+ _press_select(device, is_Q) # confirm story
+ # now create bypass PIN
+ # 1st entry
+ time.sleep(.1)
+ _login(device, is_Q, bypass_pin)
+ # 2nd confirmation entry
+ _login(device, is_Q, bypass_pin)
+
+ time.sleep(2)
+ sim.stop() # power off
+
+ sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", "22-22", "--early-usb"])
+ sim.start(start_wait=6)
+ device = ColdcardDevice(is_simulator=True)
+ secs = 5
+
+ _login(device, is_Q, bypass_pin)
+ time.sleep(.1)
+ title, story = _cap_story(device)
+ assert "Spending Policy Unlock" in story
+ _press_select(device, is_Q)
+ time.sleep(.1)
+ _login(device, is_Q, "22-22")
+
+ time.sleep(.15)
+ scr = " ".join(_cap_screen(device).split("\n"))
+ assert "Login countdown in effect" in scr
+ assert "Must wait:" in scr
+ assert f"{secs}s" in scr
+ time.sleep(secs + 1)
+ _login(device, is_Q, "22-22")
+ time.sleep(3)
+ m = _cap_menu(device)
+ assert "Ready To Sign" in m
+ sim.stop()
+
+
+def test_sssp_trick_pins(request):
+ # only testing countdown TP
+ ct_pin = "89-89"
+ bypass_pin = "15-16"
+ is_Q = request.config.getoption('--Q')
+ clean_sim_data() # remove all from previous
+ sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", "22-22", "--early-usb"])
+ sim.start(start_wait=6)
+ device = ColdcardDevice(is_simulator=True)
+ _login(device, is_Q, "22-22")
+
+ _pick_menu_item(device, is_Q, "Settings")
+ _pick_menu_item(device, is_Q, "Login Settings")
+ _pick_menu_item(device, is_Q, "Trick PINs")
+
+ # now countdown TP
+ _pick_menu_item(device, is_Q, "Add New Trick")
+ time.sleep(.1)
+
+ for ch in ct_pin[:2]:
+ _need_keypress(device, ch)
+ time.sleep(.1)
+ _press_select(device, is_Q)
+
+ if not is_Q:
+ # anti-phishing words
+ _press_select(device, is_Q)
+
+ for ch in ct_pin[-2:]:
+ _need_keypress(device, ch)
+ time.sleep(.1)
+ _press_select(device, is_Q)
+
+ _pick_menu_item(device, is_Q, "Login Countdown")
+ _press_select(device, is_Q)
+ time.sleep(.1)
+
+ _pick_menu_item(device, is_Q, "Just Countdown")
+ for _ in range(2):
+ _press_select(device, is_Q)
+ time.sleep(.1)
+
+ # adjust countdown to lowest possible value
+ _pick_menu_item(device, is_Q, f'↳{ct_pin}')
+ _pick_menu_item(device, is_Q, '↳Countdown')
+ _need_keypress(device, "4")
+ _pick_menu_item(device, is_Q, " 5 minutes")
+
+ for _ in range(10):
+ _press_cancel(device, is_Q)
+
+ time.sleep(.1)
+ _pick_menu_item(device, is_Q, "Advanced/Tools")
+ _pick_menu_item(device, is_Q, "Spending Policy")
+ _pick_menu_item(device, is_Q, "Single-Signer")
+ _press_select(device, is_Q) # confirm story
+ # now create bypass PIN
+ # 1st entry
+ time.sleep(.1)
+ _login(device, is_Q, bypass_pin)
+ # 2nd confirmation entry
+ _login(device, is_Q, bypass_pin)
+
+ time.sleep(2)
+ sim.stop()
+
+ sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", "22-22", "--early-usb"])
+ sim.start(start_wait=6)
+ device = ColdcardDevice(is_simulator=True)
+
+ _login(device, is_Q, bypass_pin)
+ time.sleep(.1)
+ title, story = _cap_story(device)
+ assert "Spending Policy Unlock" in story
+ _press_select(device, is_Q)
+ time.sleep(.1)
+ # try to log in with countdown TP instead of main
+ # send you directly into countdown
+ _login(device, is_Q, ct_pin)
+ time.sleep(.15)
+ scr = " ".join(_cap_screen(device).split("\n"))
+ assert "Login countdown in effect" in scr
+ assert "Must wait:" in scr
+ assert "5s" in scr
+ time.sleep(6)
+
+ sim.stop()
# EOF
Why this scored 29/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.