What changed, and why it matters
This commit reverts a previous security fix. The original fix prevented a 'bypass PIN' (a special trick PIN that hides the wallet) from being used a second time to finish logging in without ever entering the real main PIN. After this revert, entering the bypass PIN again at the second prompt will complete login into an empty, no-secret session, just as it did before the fix. The change also removes the bugfix note from the changelog and updates tests to expect the old, less restrictive behavior again.
Treat this as a deliberate reversion of a security control. Review the vendor's rationale for the revert, verify whether the original vulnerability is now considered acceptable behavior or mitigated elsewhere, and ensure users relying on trick PINs understand that a bypass PIN entered twice can complete login without loading secrets. If the revert is unintended, restore the has_secrets() loop and the changelog bugfix entry.
Security signals we found
Reverts a prior bugfix that enforced main PIN verification after a trick PIN unlock
Removes has_secrets() check that blocked zero-secret bypass PIN reuse at second login prompt
Changelog removal of security-relevant bugfix description
Test expectations inverted: now asserts bypass PIN alone completes login to empty wallet
Code comment explicitly notes bypass PIN could be reused and is accepted
Evidence from the diff
The commit reverts the fix for ‘SSSP bypass PIN alone could complete login into a no-secret session.’ In shared/actions.py, the loop that repeatedly prompted for the main PIN and verified pa.has_secrets() is removed. Instead, after a spending-policy trick PIN unlock, the device now does a single block_until_login() without verifying that secrets were actually loaded. The comment is updated to acknowledge that the entered PIN could again be the bypass PIN and that a duress wallet is acceptable. The changelog entry is deleted, and the test test_sssp_bypass_pin_alone_no_login is rewritten to assert the no-secret EmptyWallet menu appears rather than asserting it does not. New tests are added for countdown and look-blank trick PINs landing in the empty menu.
Changed components
shared/actions.py:start_login_sequence()releases/Next-ChangeLog.mdtesting/login_settings_tests.pyInspect captured patch +211 / −16
diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md
index df7dd7d..4142101 100644
--- a/releases/Next-ChangeLog.md
+++ b/releases/Next-ChangeLog.md
@@ -33,7 +33,6 @@ This lists the new changes that have not yet been published in a normal release.
- Bugfix: Non-standard OP_RETURN outputs shown as "null-data", hiding part of the script
- Bugfix: Over-limit CCC address-whitelist import was rejected but still modified the policy
- Bugfix: Deleting a file right after renaming it (List Files) blanked the old name, leaving the renamed file
-- Bugfix: SSSP bypass PIN alone could complete login into a no-secret session. Second prompt now requires a PIN that loads secrets.
- Bugfix: Reordered `multi(...)` multisig with same keys was misreported as name-only change. Now blocked as duplicate.
- Bugfix: Max WIF store capacity limit was ignored if saving via QR WIF visualization
- Bugfix: Force Seed XOR restore from Temporary Seed menu to remain temporary even when master seed is blank
diff --git a/shared/actions.py b/shared/actions.py
index 259294e..ffff63d 100644
--- a/shared/actions.py
+++ b/shared/actions.py
@@ -820,15 +820,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 True:
- await ux_show_story("Spending Policy Unlock: Please provide Main PIN next.")
- pa.reset()
- await block_until_login()
- if pa.has_secrets():
- break
+ await ux_show_story("Spending Policy Unlock: Please provide Main PIN next.")
+ pa.reset()
+ await block_until_login()
- # Main or duress wallet PINs are acceptable here, but zero-secret
- # trick PINs are not enough to disable spending policy.
+ # 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 occurred if that was selected by trick details
diff --git a/testing/login_settings_tests.py b/testing/login_settings_tests.py
index 3652370..2f635ba 100644
--- a/testing/login_settings_tests.py
+++ b/testing/login_settings_tests.py
@@ -501,7 +501,7 @@ def test_login_integration(request, nick, randomize, login_ctdwn, kill_btn, kill
def test_calc_login(request):
is_Q = request.config.getoption('--Q')
if not is_Q: raise pytest.skip("Q only")
-
+
clean_sim_data() # remove all from previous
sim = ColdcardSimulator(args=["--q1"])
sim.start(start_wait=6)
@@ -734,13 +734,10 @@ def test_sssp_bypass_pin_alone_no_login(request):
_login(device, is_Q, bypass_pin) # bypass PIN a 2nd time, instead of main PIN
time.sleep(1.0)
- # With the bug the device lands on the EmptyWallet menu (no-secret session).
- # With the fix the zero-secret PIN is rejected and login does not complete.
+ # device lands on the EmptyWallet menu (no-secret session).
scr = _cap_screen(device)
- assert "New Seed Words" not in scr
- assert "Import Existing" not in scr
-
- assert "provide Main PIN" in scr
+ assert "New Seed Words" in scr
+ assert "Import Existing" in scr
sim.stop()
device.close()
@@ -895,4 +892,206 @@ def test_sssp_trick_pins(request):
sim.stop()
device.close()
+
+def test_trick_countdown_twice(request):
+ # countdown TP used again after countdown does not land in empty seed menu
+ ct_pin = "89-89"
+ is_Q = request.config.getoption('--Q')
+ headless = request.config.getoption('--headless')
+ clean_sim_data() # remove all from previous
+ sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", "22-22", "--early-usb"],
+ headless=headless)
+ 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")
+
+ _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")
+
+ time.sleep(2)
+ sim.stop()
+ device.close()
+
+ sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", "22-22", "--early-usb"],
+ headless=headless)
+ sim.start(start_wait=6)
+ device = ColdcardDevice(is_simulator=True)
+
+ _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)
+
+ _login(device, is_Q, ct_pin)
+ time.sleep(.15)
+ scr = _cap_screen(device)
+ assert "New Seed Words" in scr
+ assert "Import Existing" in scr
+
+ sim.stop()
+ device.close()
+
+
+def test_wipe_countdown_trick_pin_finishes_blank(request):
+ ct_pin = "89-90"
+ is_Q = request.config.getoption('--Q')
+ headless = request.config.getoption('--headless')
+ clean_sim_data() # remove all from previous
+ sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", "22-22", "--early-usb"],
+ headless=headless)
+ 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")
+
+ _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, "Wipe & 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")
+
+ time.sleep(2)
+ sim.stop()
+ device.close()
+
+ sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", "22-22", "--early-usb"],
+ headless=headless)
+ sim.start(start_wait=6)
+ device = ColdcardDevice(is_simulator=True)
+
+ _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)
+
+ _login(device, is_Q, ct_pin)
+ time.sleep(3)
+ m = _cap_menu(device)
+ assert "New Seed Words" in m
+ assert "Import Existing" in m
+
+ sim.stop()
+ device.close()
+
+
+def test_look_blank_trick_pin_empty_menu(request):
+ blank_pin = "55-55"
+ 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")
+
+ _pick_menu_item(device, is_Q, "Add New Trick")
+ time.sleep(.1)
+
+ for ch in blank_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 blank_pin[-2:]:
+ _need_keypress(device, ch)
+ time.sleep(.1)
+ _press_select(device, is_Q)
+
+ _pick_menu_item(device, is_Q, "Look Blank")
+ for _ in range(2):
+ _press_select(device, is_Q)
+ time.sleep(.1)
+
+ time.sleep(2)
+ sim.stop()
+ device.close()
+
+ 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, blank_pin)
+ time.sleep(3)
+ m = _cap_menu(device)
+ assert "New Seed Words" in m
+ assert "Import Existing" in m
+
+ sim.stop()
+ device.close()
+
# EOF
Why this scored 61/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.