diff --git a/src/seedsigner/models/decode_qr.py b/src/seedsigner/models/decode_qr.py index 2210cfdbb..ab968b2f9 100644 --- a/src/seedsigner/models/decode_qr.py +++ b/src/seedsigner/models/decode_qr.py @@ -521,15 +521,15 @@ def detect_segment_type(s, wordlist_language_code=None): from importlib import import_module slip39_wordlist = import_module("shamir_mnemonic.wordlist").WORDLIST - if all(x in wordlist for x in s.strip().split(" ")): + if all(x in wordlist for x in s.strip().lower().split()): # checks if all words in list are in bip39 word list return QRType.SEED__MNEMONIC - elif all(x in _4LETTER_WORDLIST for x in s.strip().split(" ")): + elif all(x in _4LETTER_WORDLIST for x in s.strip().lower().split()): # checks if all 4 letter words are in list are in 4 letter bip39 word list return QRType.SEED__FOUR_LETTER_MNEMONIC - elif all(x in slip39_wordlist for x in s.strip().lower().split(" ")): + elif all(x in slip39_wordlist for x in s.strip().lower().split()): return QRType.SEED__SLIP39 elif DecodeQR.is_base43_psbt(s): @@ -992,7 +992,7 @@ def add(self, segment, qr_type=QRType.SEED__SEEDQR): elif qr_type == QRType.SEED__MNEMONIC: try: - seed_phrase_list = self.seed_phrase = segment.strip().split(" ") + seed_phrase_list = self.seed_phrase = segment.strip().lower().split() if not self.has_valid_word_count(): return DecodeQRStatus.INVALID @@ -1023,7 +1023,7 @@ def add(self, segment, qr_type=QRType.SEED__SEEDQR): elif qr_type == QRType.SEED__FOUR_LETTER_MNEMONIC: try: - seed_phrase_list = segment.strip().split(" ") + seed_phrase_list = segment.strip().lower().split() words = [] for s in seed_phrase_list: # TODO: Pre-calculate this once on startup diff --git a/src/seedsigner/models/seed.py b/src/seedsigner/models/seed.py index e73cbefd0..c2e01d6a1 100644 --- a/src/seedsigner/models/seed.py +++ b/src/seedsigner/models/seed.py @@ -33,7 +33,7 @@ def __init__(self, if not mnemonic: raise Exception("Must initialize a Seed with a mnemonic List[str]") - self._mnemonic: List[str] = unicodedata.normalize("NFKD", " ".join(mnemonic).strip()).split() + self._mnemonic: List[str] = unicodedata.normalize("NFKD", " ".join(mnemonic).strip()).lower().split() self._passphrase: str = "" self.set_passphrase(passphrase, regenerate_seed=False) diff --git a/src/seedsigner/models/seed_storage.py b/src/seedsigner/models/seed_storage.py index 3090dc710..2c84f7fba 100644 --- a/src/seedsigner/models/seed_storage.py +++ b/src/seedsigner/models/seed_storage.py @@ -82,7 +82,10 @@ def update_pending_mnemonic(self, word: str, index: int): """ if index >= len(self._pending_mnemonic): raise Exception(f"index {index} is too high") - self._pending_mnemonic[index] = word + # Create an independent copy so that wipe_list() in + # discard_pending_mnemonic() won't corrupt the shared + # global wordlist strings via wipe_string/ctypes.memset. + self._pending_mnemonic[index] = "".join(word) def get_pending_mnemonic_word(self, index: int) -> str: @@ -146,7 +149,9 @@ def init_pending_slip39_share(self, num_words: int | None = None): def update_pending_slip39_share(self, word: str, index: int): if index >= len(self._pending_slip39_share): raise Exception(f"index {index} is too high") - self._pending_slip39_share[index] = word + # Create an independent copy so that wipe_list() won't + # corrupt the shared global SLIP-39 wordlist strings. + self._pending_slip39_share[index] = "".join(word) def get_pending_slip39_word(self, index: int) -> str: if index < len(self._pending_slip39_share): diff --git a/tests/test_decodepsbtqr.py b/tests/test_decodepsbtqr.py index c0fa6aa1a..774abf5b2 100644 --- a/tests/test_decodepsbtqr.py +++ b/tests/test_decodepsbtqr.py @@ -453,6 +453,52 @@ def test_seed_qr(): assert d.get_seed_phrase() == "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash".split() +def test_mnemonic_text_qr_case_insensitive(): + """Text QR codes containing BIP39 mnemonics should be detected regardless of case.""" + expected_phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".split() + + # Lowercase (standard) + d = DecodeQR() + d.add_data("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about") + assert d.qr_type == QRType.SEED__MNEMONIC + assert d.get_seed_phrase() == expected_phrase + + # Capitalized words + d = DecodeQR() + d.add_data("Abandon Abandon Abandon Abandon Abandon Abandon Abandon Abandon Abandon Abandon Abandon About") + assert d.qr_type == QRType.SEED__MNEMONIC + assert d.get_seed_phrase() == expected_phrase + + # Uppercase words + d = DecodeQR() + d.add_data("ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABOUT") + assert d.qr_type == QRType.SEED__MNEMONIC + assert d.get_seed_phrase() == expected_phrase + + +def test_mnemonic_text_qr_whitespace_tolerant(): + """Text QR codes with non-standard whitespace should still be detected.""" + expected_phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".split() + + # Double spaces + d = DecodeQR() + d.add_data("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about") + assert d.qr_type == QRType.SEED__MNEMONIC + assert d.get_seed_phrase() == expected_phrase + + # Newline-separated + d = DecodeQR() + d.add_data("abandon\nabandon\nabandon\nabandon\nabandon\nabandon\nabandon\nabandon\nabandon\nabandon\nabandon\nabout") + assert d.qr_type == QRType.SEED__MNEMONIC + assert d.get_seed_phrase() == expected_phrase + + # Multi-line with mixed whitespace + d = DecodeQR() + d.add_data("abandon abandon abandon abandon\nabandon abandon abandon abandon\nabandon abandon abandon about") + assert d.qr_type == QRType.SEED__MNEMONIC + assert d.get_seed_phrase() == expected_phrase + + def test_specter_wallet_json(): parts = [ 'p1of3 {"label": "SeedSigner Dev Funds", "blockheight": 692143, "descriptor": "wsh(sortedmulti(4,[e0811b6b/48h/0h/0h/2h]xpub6E8v7uy63pCeJvHe5W8ea8zTnCtKMFgMRb5bueWWcUFMw6sWmUwTqxM8cFiKQRWkA2Fxth9HJZufJwjWTTvU1UGZNpTrh9khrswYMgeHiCt/0/*,[852b308f/48h/0h/0h/2h]xpub6ErhgAWfnEqW7xDBm1iLq5JjNyUS65YUFnjHLrRv9zmdDEtuE75bpWQ8o6bSBnpT6AkrrsA8eA5SmEFArZn11KEPaZJzx9mHTXPWZCsxLyh/0/*,[7edf9c59/48h/0h/0h/2h]xpub6DaFfKoe7Wpofr' diff --git a/tests/test_seed.py b/tests/test_seed.py index e729c11d6..3dbb94d87 100644 --- a/tests/test_seed.py +++ b/tests/test_seed.py @@ -42,7 +42,50 @@ def test_seed(): # assert seed.passphrase == "test" - + + + +def test_seed_case_insensitive(): + """Mnemonic words should be accepted regardless of case.""" + expected_bytes = Seed(mnemonic="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".split()).seed_bytes + + # Capitalized words + seed = Seed(mnemonic="Abandon Abandon Abandon Abandon Abandon Abandon Abandon Abandon Abandon Abandon Abandon About".split()) + assert seed.seed_bytes == expected_bytes + assert seed.mnemonic_str == "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + # Uppercase words + seed = Seed(mnemonic="ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABOUT".split()) + assert seed.seed_bytes == expected_bytes + + # Mixed case words + seed = Seed(mnemonic="aBaNdOn ABANDON abandon Abandon ABANDON abandon ABANDON Abandon abandon ABANDON abandon About".split()) + assert seed.seed_bytes == expected_bytes + + +def test_discard_pending_mnemonic_does_not_corrupt_wordlist(): + """Regression test: wipe_list in discard_pending_mnemonic must not corrupt + the global bip39.WORDLIST via wipe_string/ctypes.memset.""" + from embit import bip39 + from seedsigner.models.seed_storage import SeedStorage + + original_first_word = bip39.WORDLIST[0] # "abandon" + assert original_first_word == "abandon" + + storage = SeedStorage() + storage.init_pending_mnemonic(num_words=12) + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".split() + for i, word in enumerate(mnemonic): + storage.update_pending_mnemonic(word, i) + + storage.convert_pending_mnemonic_to_pending_seed() + + # The global wordlist must still be intact + assert bip39.WORDLIST[0] == "abandon" + assert bip39.WORDLIST[0].startswith("a") + assert repr(bip39.WORDLIST[0]) == "'abandon'" + # Word matching (as used in keyboard entry) must still work + assert "abandon" in [w for w in bip39.WORDLIST if w.startswith("a")]