bugfix: reject non-ASCII BIP-39 passphrases
What changed, and why it matters
This update fixes a bug where COLDCARD wallets would accept BIP-39 passphrases containing non-ASCII characters (like accented letters or emojis) or non-printable characters. Because different wallet software normalizes such characters differently, the same passphrase could silently produce a different wallet address on COLDCARD than on another app. The fix now rejects those passphrases outright, preventing users from accidentally creating funds they later cannot access or reconcile with other wallets.
Users should upgrade to a firmware release containing this fix. Until then, avoid using non-ASCII or non-printable characters in BIP-39 passphrases, and verify any existing passphrase-derived wallets against other BIP-39 software before relying on them for funds.
Security signals we found
Behavioral fix that prevents silent derivation of incompatible wallet seeds
Input validation added for BIP-39 passphrase across USB, UI, saved-passphrase restore, and note/password lanes
Rejection of non-printable ASCII and non-ASCII Unicode characters
Test coverage added for Unicode normalization edge cases (NFC/NFD), emoji, tab, newline, and QR entry paths
Evidence from the diff
The commit adds validation that BIP-39 passphrases must consist only of printable ASCII characters (0x20-0x7e). It introduces validate_bip39_passphrase() in shared/seed.py and calls it before applying a passphrase via the UI, saved-passphrase restore, and note/password lanes. It also adds the same check in shared/usb.py for passphrases submitted over the USB protocol. Tests are added to confirm rejection of NFC/NFD Unicode forms, emojis, tabs, and line breaks, while ASCII passphrases including spaces continue to work.
Changed components
shared/seed.pyshared/usb.pyBIP-39 passphrase entry UISaved passphrase restore featureNotes/password lane-to-passphrase featureUSB BIP39Passphrase command handlerInspect captured patch +155 / −14
### releases/Next-ChangeLog.md
@@ -43,6 +43,9 @@ This lists the new changes that have not yet been published in a normal release.
- Bugfix: In Delta Mode, wipe the seed if anyone tries to view or activate a duress
wallet's secret from the Trick PINs menu, instead of revealing it. Browsing the menu
itself still works, so Delta Mode continues to look like normal operation.
+- Bugfix: Reject non-ASCII BIP-39 passphrases (USB, saved-passphrase recall, and
+ note/password lanes) instead of silently deriving a wallet incompatible with
+ BIP-39-normalizing software.
# Mk Specific Changes
### shared/seed.py
@@ -15,6 +15,7 @@
from menu import MenuItem, MenuSystem
from utils import xfp2str, parse_extended_key, swab32
from utils import deserialize_secret, problem_file_line, wipe_if_deltamode
+from utils import to_ascii_printable
from uhashlib import sha256
from ux import ux_show_story, the_ux, ux_dramatic_pause, ux_confirm, OK, X
from ux import PressRelease, ux_input_text, show_qr_code, ux_clear_keys
@@ -1002,6 +1003,18 @@ def set_seed_value(words=None, encoded=None, chain=None):
dis.busy_bar(False)
+async def validate_bip39_passphrase(pw):
+ try:
+ to_ascii_printable(pw, allow_tab_nl=False)
+ except AssertionError:
+ await ux_show_story(
+ "BIP-39 passphrase must use ASCII characters 32-126 (0x20-0x7e).",
+ title="Failure")
+ return False
+
+ return True
+
+
async def calc_bip39_passphrase(pw, bypass_tmp=False):
# Returns (new) encoded secret, new xfp, old xfp
from glob import dis, settings
@@ -1019,6 +1032,9 @@ async def calc_bip39_passphrase(pw, bypass_tmp=False):
return nv, xfp, current_xfp
async def set_bip39_passphrase(pw, bypass_tmp=False, summarize_ux=True):
+ if not await validate_bip39_passphrase(pw):
+ return False
+
nv, xfp, parent_xfp = await calc_bip39_passphrase(pw, bypass_tmp=bypass_tmp)
ret = await set_ephemeral_seed(nv, summarize_ux=summarize_ux, bip39pw=pw,
@@ -1651,6 +1667,9 @@ async def done_apply(cls, *a):
async def apply_pass_value(new_pp):
# Apply provided BIP-39 passphrase to master or current active tmp seed
# and go to top menu.
+ if not await validate_bip39_passphrase(new_pp):
+ return False
+
nv, xfp, parent_xfp = await calc_bip39_passphrase(new_pp)
xfp_str = xfp2str(xfp)
parent_xfp_str = xfp2str(parent_xfp)
### shared/usb.py
@@ -9,7 +9,7 @@
from public_constants import STXN_FLAGS_MASK
from ustruct import pack, unpack_from
from ckcc import watchpoint, is_simulator
-from utils import problem_file_line, call_later_ms
+from utils import problem_file_line, call_later_ms, to_ascii_printable
from version import supports_hsm, is_devmode, MAX_TXN_LEN, MAX_UPLOAD_LEN
from exceptions import FramingError, CCBusyError, HSMDenied, HSMCMDDisabled, SpendPolicyViolation
from pincodes import pa
@@ -599,10 +599,10 @@ async def handle(self, cmd, args):
assert self.encrypted_req, 'must encrypt'
from auth import start_bip39_passphrase
from glob import settings
-
assert settings.get("words", True), 'no seed'
assert len(args) < 400, 'too long'
pw = str(args, 'utf8')
+ to_ascii_printable(pw, allow_tab_nl=False)
assert len(pw), 'too short'
assert len(pw) < 100, 'too long'
### testing/conftest.py
@@ -1767,7 +1767,7 @@ def doit(qr):
if not is_q1:
raise pytest.xfail('needs scanner')
assert isinstance(qr, str)
- qr = qr.encode('ascii')
+ qr = qr.encode('utf8')
rv = sim_exec(f'glob.SCAN._q.put_nowait({qr!r})')
if 'Traceback' in rv: raise pytest.fail(rv)
### testing/test_bip39pw.py
@@ -12,6 +12,7 @@
from mnemonic import Mnemonic
from constants import simulator_fixed_xfp, simulator_fixed_words, simulator_fixed_tprv
from helpers import xfp2str
+from charcodes import KEY_QR
# add the BIP39 test vectors
vectors = json.load(open('bip39-vectors.json'))['english']
@@ -138,7 +139,7 @@ def doit(pw, reset=True, seed_vault=False, on_tmp=False):
return doit
-@pytest.mark.parametrize('pw', [
+@pytest.mark.parametrize('pw', [
'a'*1000, # way too big
'a'*100, # just too big
])
@@ -147,6 +148,73 @@ def test_b39_fails(dev, pw):
with pytest.raises(CCProtoError):
dev.send_recv(CCProtocolPacker.bip39_passphrase(pw), timeout=None)
+@pytest.mark.parametrize('pw', [
+ 'café', # NFC form
+ 'café', # NFD form
+ 'emoji 🚀 inside',
+ 'tab\tinside',
+ 'line\nbreak',
+ ])
+def test_b39_non_ascii_or_non_printable_rejected(dev, pw):
+ # non-ASCII and non-printable passphrases are rejected at entry
+ with pytest.raises(CCProtoError) as e:
+ dev.send_recv(CCProtocolPacker.bip39_passphrase(pw), timeout=None)
+ assert 'ascii' in str(e.value)
+
+def test_b39_ascii_still_works(dev, set_bip39_pw, reset_seed_words):
+ # ASCII passphrases (with spaces) are unaffected
+ try:
+ set_bip39_pw('with some spaces')
+ finally:
+ reset_seed_words()
+
+def test_b39_non_ascii_qr_rejected(dev, is_q1, reset_seed_words, go_to_passphrase,
+ need_keypress, scan_a_qr, press_select, cap_story,
+ cap_menu, cap_screen):
+ if not is_q1:
+ raise pytest.skip("Q only")
+
+ reset_seed_words()
+ before = dev.send_recv(CCProtocolPacker.get_xpub("m"), timeout=None)
+
+ go_to_passphrase()
+ need_keypress(KEY_QR)
+
+ for _ in range(20):
+ if "Scan any QR" in cap_screen():
+ break
+ time.sleep(.1)
+ assert "Scan any QR" in cap_screen()
+
+ scan_a_qr('café 🚀')
+
+ for _ in range(20):
+ if "Your BIP-39 Passphrase" in cap_screen():
+ break
+ time.sleep(.1)
+ assert "Your BIP-39 Passphrase" in cap_screen()
+
+ press_select()
+
+ for _ in range(20):
+ title, story = cap_story()
+ if title == "Failure":
+ break
+ time.sleep(.1)
+
+ assert title == "Failure"
+ assert "ASCII characters 32-126" in story
+ assert dev.send_recv(CCProtocolPacker.get_xpub("m"), timeout=None) == before
+
+ press_select()
+
+ for _ in range(20):
+ menu = cap_menu()
+ if "Passphrase" in menu:
+ break
+ time.sleep(.1)
+ assert "Passphrase" in menu
+
def test_b39p_refused(dev, press_cancel, pw='testing 123'):
# user can refuse the passphrase (cancel)
### testing/test_notes.py
@@ -862,29 +862,44 @@ def test_top_import(way, encrypted, goto_notes, cap_menu, cap_story, need_keypre
goto_notes()
+@pytest.mark.parametrize("password", [False, True])
def test_top_import_u_typed_json(goto_notes, cap_menu, cap_story, need_keypress,
- settings_get, settings_set, scan_a_qr):
+ settings_get, settings_set, scan_a_qr,
+ pick_menu_item, password):
settings_set('notes', [])
goto_notes('Import')
need_keypress(KEY_QR)
- notes = {"coldcard_notes": [{"title": "demo", "misc": "x"}]}
+ note = {"title": "demo", "misc": "café" if not password else "x"}
+ if password:
+ note.update(password="café", user="user", site="example.com")
+ notes = {"coldcard_notes": [note]}
jj = json.dumps(notes)
_, parts = split_qrs(jj, 'U', max_version=20) # deliberately U-typed
for p in parts:
scan_a_qr(p)
- time.sleep(.5)
- m = cap_menu()
- for _ in range(3):
- if "1:" in m[0]:
- break
- time.sleep(.2)
+ for _ in range(20):
m = cap_menu()
+ if m and "1:" in m[0]:
+ break
+ time.sleep(.1)
+ assert m and "1:" in m[0]
assert settings_get('notes') == notes["coldcard_notes"]
goto_notes()
+ pick_menu_item("1: demo")
+
+ expect = "View Password" if password else "View Note"
+ for _ in range(20):
+ menu = cap_menu()
+ if expect in menu:
+ break
+ time.sleep(.1)
+
+ assert expect in menu
+ assert "Apply as BIP-39 Passphrase" not in menu
@pytest.mark.parametrize('bkpw', [True, False])
### testing/test_pwsave.py
@@ -2,9 +2,11 @@
#
# tests for ../shared/pwsave.py
#
-import pytest, time, os, shutil
+import pytest, time, os, shutil, json
+import pyaes
from binascii import a2b_hex
-from constants import simulator_fixed_tprv
+from ckcc.protocol import CCProtocolPacker
+from constants import simulator_fixed_tprv, simulator_fixed_xfp
@pytest.fixture
@@ -142,6 +144,40 @@ def test_crypto_unittest(sim_exec, simulator, simulator_db_file):
assert j[0]['pw']
assert j[0]['xfp']
+def test_restore_non_ascii_rejected(dev, sim_exec, simulator_db_file,
+ garbage_collector, reset_seed_words,
+ go_to_passphrase, pick_menu_item, cap_menu,
+ cap_story, press_select):
+ reset_seed_words()
+ before = dev.send_recv(CCProtocolPacker.get_xpub("m"), timeout=None)
+
+ key = sim_exec('''\
+import files; from h import b2a_hex; from pwsave import PassphraseSaver; \
+cs = files.CardSlot().__enter__(); p = PassphraseSaver(); \
+p._calc_key(cs); RV.write(b2a_hex(p.key)); cs.__exit__()''')
+ records = [{"xfp": simulator_fixed_xfp, "pw": "café"}]
+ enc = pyaes.AESModeOfOperationCTR(a2b_hex(key), pyaes.Counter(0)).encrypt(
+ json.dumps(records).encode())
+ with open(simulator_db_file(), "wb") as fd:
+ fd.write(enc)
+ garbage_collector.append(simulator_db_file())
+
+ go_to_passphrase()
+ pick_menu_item("Restore Saved")
+ time.sleep(.2)
+ pick_menu_item(cap_menu()[0])
+ time.sleep(.2)
+ pick_menu_item("Restore")
+ time.sleep(.2)
+ title, story = cap_story()
+ assert title == "Failure"
+ assert "ASCII characters 32-126" in story
+ assert dev.send_recv(CCProtocolPacker.get_xpub("m"), timeout=None) == before
+
+ press_select()
+ time.sleep(.2)
+ assert "Restore" in cap_menu()
+
def test_delete_one_by_one(go_to_passphrase, pick_menu_item, cap_menu,
cap_story, press_select, src_root_dir, sim_root_dir):
# delete it one by oneWhy this scored 62/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.