Q becomes calculator rather than e-waste
What changed, and why it matters
This update changes what happens on the COLDCARD Q hardware wallet after someone enters the wrong PIN 13 times and the device becomes permanently locked ("bricked"). Instead of showing only a useless "I Am Brick!" screen, the Q model now turns into a simple calculator. The change also lets the calculator's login prompt check whether PIN attempts remain before trying to log in, and adds simulator options so developers can test bricked devices. There is no direct evidence in the commit that this fixes an active security vulnerability; it reads as a usability enhancement for a device that is already considered lost.
Treat as a feature/usability patch rather than a security fix. Review the commented-out assertions in shared/pincodes.py to confirm they were intentionally disabled and do not mask a struct-layout issue. Verify that calc.login_repl() cannot leak secrets or perform cryptographic operations once the secure element is bricked.
Security signals we found
PIN-gated calculator access on bricked devices
Removal of allow_login flag in favor of runtime PIN-attempt check
New simulator flags for bricked-state testing
Unexplained assertion removals in PIN-attempt struct parsing
Evidence from the diff
The commit modifies the bricking behavior for the Q (qwerty) model. In shared/login.py, the we_are_ewaste() message now says “Calculator mode starts now” on Q devices and launches calc.login_repl(). In shared/pincodes.py, enforce_brick() loops into login_repl() for Q models instead of entering DFU. In shared/calc.py, the login_repl() signature drops the allow_login parameter and instead consults pa.attempts_left before attempting pa.setup()/pa.login() or pa.prefix_words(). Simulator support is added via –bricked and –fails flags. The change also comments out three struct-format assertions in pincodes.py, which is unrelated to the calculator feature and not explained in the commit message.
Changed components
shared/login.pyshared/pincodes.pyshared/calc.pyshared/main.pyunix/variant/ckcc.pyunix/variant/sim_settings.pyInspect captured patch +39 / −16
diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md
index e70390e..f7077e5 100644
--- a/releases/Next-ChangeLog.md
+++ b/releases/Next-ChangeLog.md
@@ -44,6 +44,8 @@ Spending policies for "Single Signers" adds new spending policy options:
## 1.3.4Q - 2025-09-2x
+- Enhancement: Enter "forever calculator" mode when Q would otherwise be e-waste: after 13
+ PIN failures, when device is bricked.
- Bugfix: Correct line positioning when 24 seed words displayed.
diff --git a/shared/calc.py b/shared/calc.py
index 4c1e10b..8e31e4a 100644
--- a/shared/calc.py
+++ b/shared/calc.py
@@ -8,8 +8,9 @@ import utime, ngu, re
from utils import B2A, word_wrap
from ux_q1 import ux_input_text
-async def login_repl(allow_login=True):
+async def login_repl():
from glob import dis
+ from pincodes import pa
NUM_LINES = 7 # 10 - title - 2 for prompt
@@ -64,12 +65,11 @@ Example Commands:
elif ln in ('help', 'cls', 'rand'):
# no need for () for these commands
ans = state[ln]()
- elif allow_login and re_pin.match(ln) and (len(ln) <= 13):
+ elif pa.attempts_left and re_pin.match(ln) and (len(ln) <= 13):
# try login
m = re_pin.match(ln)
ln = m.group(1)+ '-' + m.group(2)
- print(ln)
- from pincodes import pa
+
try:
pa.setup(ln)
ok = pa.login()
@@ -83,7 +83,7 @@ Example Commands:
else:
ans = 'Error: ' + repr(exc.args)
- elif allow_login and re_prefix.match(ln) and (len(ln) <= 7):
+ elif re_prefix.match(ln) and (len(ln) <= 7):
# show words
from pincodes import pa
ans = pa.prefix_words(ln[:-1].encode())
diff --git a/shared/login.py b/shared/login.py
index bd9636e..97da5b2 100644
--- a/shared/login.py
+++ b/shared/login.py
@@ -181,14 +181,22 @@ class LoginUX:
async def we_are_ewaste(self, num_fails):
msg = '''After %d failed PIN attempts this Coldcard is locked forever. \
By design, there is no way to reset or recover the secure element, and its contents \
-are now forever inaccessible.
+are now forever inaccessible.\n\n''' % num_fails
-Restore your seed words onto a new Coldcard.''' % num_fails
+ if has_qwerty:
+ msg += 'Calculator mode starts now.'
+ else:
+ msg += 'Restore your seed words onto a new Coldcard.'
while 1:
ch = await ux_show_story(msg, title='I Am Brick!', escape='6')
if ch == '6': break
+ if has_qwerty:
+ from calc import login_repl
+ await login_repl()
+
+
async def confirm_attempt(self, attempts_left, value):
ch = await ux_show_story('''You have %d attempts left before this Coldcard BRICKS \
diff --git a/shared/main.py b/shared/main.py
index 87c5822..afacb7e 100644
--- a/shared/main.py
+++ b/shared/main.py
@@ -90,7 +90,7 @@ async def more_setup():
from pincodes import pa
# check for bricked system early
# bricked CC not going past this point
- pa.enforce_brick()
+ await pa.enforce_brick()
pa.setup(b'') # just to see where we stand.
is_blank = pa.is_blank()
diff --git a/shared/pincodes.py b/shared/pincodes.py
index 863b860..d2cea54 100644
--- a/shared/pincodes.py
+++ b/shared/pincodes.py
@@ -130,9 +130,10 @@ class PinAttempt:
# seed, we are not going to let them see it, nor sign things we dont like, etc.
self.hobbled_mode = False
- assert MAX_PIN_LEN == 32 # update FMT otherwise
- assert ustruct.calcsize(PIN_ATTEMPT_FMT_V1) == PIN_ATTEMPT_SIZE_V1
- assert ustruct.calcsize(PIN_ATTEMPT_FMT_V2_ADDITIONS) == PIN_ATTEMPT_SIZE - PIN_ATTEMPT_SIZE_V1
+ #assert MAX_PIN_LEN == 32 # update FMT otherwise
+ #assert ustruct.calcsize(PIN_ATTEMPT_FMT_V1) == PIN_ATTEMPT_SIZE_V1
+ #assert ustruct.calcsize(PIN_ATTEMPT_FMT_V2_ADDITIONS) \
+ # == PIN_ATTEMPT_SIZE - PIN_ATTEMPT_SIZE_V1
def __repr__(self):
return '<PinAttempt: fails/left=%d/%d tc_flag/arg=0x%x/0x%x>' % (
@@ -535,11 +536,10 @@ class PinAttempt:
# check for bricked system early
if get_is_bricked():
try:
- # regardless of settings.calc forever calculator after brickage
- # for Q models fom version 5.X.X
- if version.has_qwerty:
+ # regardless of settings, become a forever calculator after brickage.
+ while version.has_qwerty:
from calc import login_repl
- await login_repl(allow_login=False)
+ await login_repl()
finally:
# die right away if it's not going to work
enter_dfu(3)
diff --git a/unix/README.md b/unix/README.md
index 481102d..27e2575 100644
--- a/unix/README.md
+++ b/unix/README.md
@@ -53,7 +53,7 @@ wallet (on testnet, always with the same seed). But there are other options:
- `--deriv` => go to the Derive Entropy menu inside settings, also loads XPRV from BIP
- `--secret 01abababab...` => directly set contents of SE secret, see SecretStash.encode()
- `--eject` => pretend no (simulated) SD Card is inserted
-- `--eff` => (mk4) wipe setttings at startup, use simulator defaults
+- `--eff` => wipe setttings at startup, use simulator defaults, save nothing.
- `--seq 1234yx34` => after start, enter those keypresses to get you to some submenu
- `--seq 2ENTER` => (Q) press 2 then ENTER, does QR at startup
- `--bootup-movie` => begin a movie on startup, to capture boot sequence
@@ -61,6 +61,8 @@ wallet (on testnet, always with the same seed). But there are other options:
- `--battery` => (Q) assume the USB cable is NOT connected (ie. on battery power)
- `--early-usb` => start simulated USB interface even before user is login (useful for login testing)
- `--segregate` => scroll down to `Running simulators in parallel` section
+- `--bricked` => simulate a system w/ bricked SE1: no more pin tries, etc.
+- `--fails N` => simulate N wrong PIN attempts before login, where (1 <= N <= 13)
See `variant/sim_settings.py` for the details of settings-related options.
diff --git a/unix/variant/ckcc.py b/unix/variant/ckcc.py
index 99b46f3..d634a3c 100644
--- a/unix/variant/ckcc.py
+++ b/unix/variant/ckcc.py
@@ -98,6 +98,9 @@ def gate(method, buf_io, arg2):
if method == 5:
# are we a brick? No.
+ if '--bricked' in sys.argv:
+ # if SE1 has pairing secret rotated; wont be able to do much
+ return 1
return 0
if method == 6:
diff --git a/unix/variant/sim_quickstart.py b/unix/variant/sim_quickstart.py
index 476d8ab..4145983 100644
--- a/unix/variant/sim_quickstart.py
+++ b/unix/variant/sim_quickstart.py
@@ -155,6 +155,7 @@ if '--enter' in sys.argv:
# keep at end of file: extra enter to confirm something from above
numpad.inject('y')
+
# not best place for this
import hsm
hsm.POLICY_FNAME = hsm.POLICY_FNAME.replace('/flash/', '')
diff --git a/unix/variant/sim_settings.py b/unix/variant/sim_settings.py
index 7823545..01392d8 100644
--- a/unix/variant/sim_settings.py
+++ b/unix/variant/sim_settings.py
@@ -144,6 +144,13 @@ if '-g' in sys.argv:
# do login.. but does not work if _skip_pin got saved into settings already
sim_defaults.pop('_skip_pin', 0)
+if '--fails' in sys.argv:
+ # fast-forward as if N PIN failures have already happened.
+ count = int(sys.argv[sys.argv.index('--fails') + 1])
+ import ckcc
+ ckcc.SE_STATE.force_fails(count)
+ sim_defaults.pop('_skip_pin', 0)
+
if '--nick' in sys.argv:
nick = sys.argv[sys.argv.index('--nick') + 1]
sim_defaults['nick'] = nick
Why this scored 27/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.