Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/seedsigner/helpers/mnemonic_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,52 @@ def generate_mnemonic_from_image(image, wordlist_language_code: str = SettingsCo

# Return as a list
return bip39.mnemonic_from_bytes(hash.digest(), wordlist=Seed.get_wordlist(wordlist_language_code)).split()



def get_valid_final_mnemonic_words(partial_mnemonic: list, wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> list[str]:
"""
Calculate all valid final words that would produce a valid checksum for the given partial mnemonic.

For 12-word seeds (7 bit entropy + 4 bit checksum): exactly 128 valid words out of 2048
For 24-word seeds (3 bit entropy + 8 bit checksum): exactly 8 valid words out of 2048
"""
wordlist = Seed.get_wordlist(wordlist_language_code)
total_bits = 128 if len(partial_mnemonic) == 11 else 256
entropy_bits = 0

# convert partial mnemonic words to entropy bits.
# left shift the current entropy_bits by 11 and combine the shifted value with word index.
# this helps us calculate the correct checksum
for word in partial_mnemonic:
entropy_bits = (entropy_bits << 11) | wordlist.index(word)

final_entropy_bits = total_bits - len(partial_mnemonic) * 11 # each word has 11 bits of entropy
valid_indices = []

# brute force all possible final entropy values
# 12-word seeds: 2^7 = 128 iterations
# 24-word seeds: 2^3 = 8 iterations
for i in range(1 << final_entropy_bits):
# combine existing entropy with candidate final entropy bits
full_entropy = (entropy_bits << final_entropy_bits) | i

# convert to bytes for SHA256 (crypto functions need byte input, not integers)
entropy_bytes = full_entropy.to_bytes(total_bits//8, 'big')

# BIP-39 checksum: first byte of SHA256 hash contains the checksum bits
checksum = hashlib.sha256(entropy_bytes).digest()[0]

# construct final word index from entropy + checksum bits
if len(partial_mnemonic) == 11:
# 12-word seed: use top 4 bits of checksum byte (>> 4 extracts bits 7-4)
checksum_bits = checksum >> 4
final_index = (i << 4) | checksum_bits
else:
# 24-word seed: use all 8 bits of checksum byte
# combine 3 entropy bits (shifted left) + 8 checksum bits
final_index = (i << 8) | checksum

valid_indices.append(final_index)

return [wordlist[i] for i in sorted(valid_indices)]
28 changes: 6 additions & 22 deletions src/seedsigner/models/seed_storage.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from typing import List
from seedsigner.models.seed import Seed, ElectrumSeed, InvalidSeedException
from seedsigner.models.seed import Seed, InvalidSeedException
from seedsigner.models.settings_definition import SettingsConstants


Expand All @@ -9,7 +9,7 @@ def __init__(self) -> None:
self.seeds: List[Seed] = []
self.pending_seed: Seed = None
self._pending_mnemonic: List[str] = []
self._pending_is_electrum : bool = False
self._pending_Seed_cls = None


def set_pending_seed(self, seed: Seed):
Expand All @@ -35,15 +35,6 @@ def clear_pending_seed(self):
self.pending_seed = None


def validate_mnemonic(self, mnemonic: List[str]) -> bool:
try:
Seed(mnemonic=mnemonic)
except InvalidSeedException as e:
return False

return True


def num_seeds(self):
return len(self.seeds)

Expand All @@ -59,9 +50,9 @@ def pending_mnemonic_length(self) -> int:
return len(self._pending_mnemonic)


def init_pending_mnemonic(self, num_words:int = 12, is_electrum:bool = False):
def init_pending_mnemonic(self, num_words:int = 12, seed_class = Seed):
self._pending_mnemonic = [None] * num_words
self._pending_is_electrum = is_electrum
self._pending_Seed_cls = seed_class


def update_pending_mnemonic(self, word: str, index: int):
Expand All @@ -83,23 +74,16 @@ def get_pending_mnemonic_word(self, index: int) -> str:

def get_pending_mnemonic_fingerprint(self, network: str = SettingsConstants.MAINNET) -> str:
try:
if self._pending_is_electrum:
seed = ElectrumSeed(self._pending_mnemonic)
else:
seed = Seed(self._pending_mnemonic)
seed = self._pending_Seed_cls(self._pending_mnemonic)
return seed.get_fingerprint(network)
except InvalidSeedException:
return None


def convert_pending_mnemonic_to_pending_seed(self):
if self._pending_is_electrum:
self.pending_seed = ElectrumSeed(self._pending_mnemonic)
else:
self.pending_seed = Seed(self._pending_mnemonic)
self.pending_seed = self._pending_Seed_cls(self._pending_mnemonic)
self.discard_pending_mnemonic()


def discard_pending_mnemonic(self):
self._pending_mnemonic = []
self._pending_is_electrum = False
26 changes: 23 additions & 3 deletions src/seedsigner/views/seed_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from seedsigner.gui.screens.screen import ButtonOption
from seedsigner.models.encode_qr import CompactSeedQrEncoder, GenericStaticQrEncoder, SeedQrEncoder, SpecterXPubQrEncoder, StaticXpubQrEncoder, UrXpubQrEncoder
from seedsigner.models.qr_type import QRType
from seedsigner.models.seed import Seed
from seedsigner.models.seed import Seed, ElectrumSeed
from seedsigner.models.settings import Settings, SettingsConstants
from seedsigner.models.settings_definition import SettingsDefinition
from seedsigner.models.threads import BaseThread, ThreadsafeCounter
Expand Down Expand Up @@ -217,12 +217,32 @@ def __init__(self, cur_word_index: int = 0, is_calc_final_word: bool=False):


def run(self):
wordlist = Seed.get_wordlist(wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE))
is_last_word_index = self.cur_word_index == self.controller.storage.pending_mnemonic_length - 1
is_normal_seed_entry_mode = not self.is_calc_final_word
is_bip39_seed = self.controller.storage._pending_Seed_cls == Seed

predict_final_seed_word = (
is_last_word_index and
is_normal_seed_entry_mode and
is_bip39_seed
)

# Filter wordlist for final word entry if needed
partial_mnemonic = None
if predict_final_seed_word:
partial_mnemonic = [word for word in self.controller.storage.pending_mnemonic if word is not None]

from seedsigner.helpers.mnemonic_generation import get_valid_final_mnemonic_words
valid_final_words = get_valid_final_mnemonic_words(partial_mnemonic)
wordlist = valid_final_words

ret = self.run_screen(
seed_screens.SeedMnemonicEntryScreen,
# TRANSLATOR_NOTE: Inserts the word number (e.g. "Seed Word #6")
title=_("Seed Word #{}").format(self.cur_word_index + 1), # Human-readable 1-indexing!
initial_letters=list(self.cur_word) if self.cur_word else ["a"],
wordlist=Seed.get_wordlist(wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)),
wordlist=wordlist,
)

if ret == RET_CODE__BACK_BUTTON:
Expand Down Expand Up @@ -514,7 +534,7 @@ def run(self):
show_back_button=False,
)

self.controller.storage.init_pending_mnemonic(num_words=12, is_electrum=True)
self.controller.storage.init_pending_mnemonic(num_words=12, seed_class=ElectrumSeed)

return Destination(SeedMnemonicEntryView)

Expand Down
32 changes: 1 addition & 31 deletions tests/test_flows_seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,36 +90,6 @@ def test_with_mnemonic(mnemonic):
test_with_mnemonic("cotton artefact spy mind wing there echo steak child oak awful host despair online bicycle divorce middle firm diamond rare execute chimney almost hollow".split())


def test_invalid_mnemonic(self):
""" Should be able to go back and edit or discard an invalid mnemonic """
# Test data from iancoleman.io
mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split()
sequence = [
FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS),
FlowStep(seed_views.SeedsMenuView, is_redirect=True), # When no seeds are loaded it auto-redirects to LoadSeedView
FlowStep(seed_views.LoadSeedView, button_data_selection=seed_views.LoadSeedView.TYPE_12WORD if len(mnemonic) == 12 else seed_views.LoadSeedView.TYPE_24WORD),
]
for word in mnemonic[:-1]:
sequence.append(FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word))

sequence += [
FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value="zoo"), # But finish with an INVALID checksum word
FlowStep(seed_views.SeedMnemonicInvalidView, button_data_selection=seed_views.SeedMnemonicInvalidView.EDIT),
]

# Restarts from first word
for word in mnemonic[:-1]:
sequence.append(FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word))

sequence += [
FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value="zebra"), # provide yet another invalid checksum word
FlowStep(seed_views.SeedMnemonicInvalidView, button_data_selection=seed_views.SeedMnemonicInvalidView.DISCARD),
FlowStep(MainMenuView),
]

self.run_sequence(sequence)


def test_electrum_mnemonic_entry_flow(self):
"""
Manually entering an Electrum mnemonic should land at the Finalize Seed flow and end at
Expand Down Expand Up @@ -372,7 +342,7 @@ def test_export_xpub_electrum_seed_flow(self):
Electrum seeds should skip script type selection
"""
# Load a finalized Seed into the Controller
self.controller.storage.init_pending_mnemonic(num_words=12, is_electrum=True)
self.controller.storage.init_pending_mnemonic(num_words=12, seed_class=ElectrumSeed)
self.controller.storage.set_pending_seed(ElectrumSeed("regular reject rare profit once math fringe chase until ketchup century escape".split()))
self.controller.storage.finalize_pending_seed()

Expand Down
33 changes: 33 additions & 0 deletions tests/test_mnemonic_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,36 @@ def test_50_dice_rolls():
actual = " ".join(mnemonic)
assert bip39.mnemonic_is_valid(actual)
assert actual == expected


def test_valid_final_mnemonic_words():
""" Test valid final words for 12-word and 24-word seeds. """
partial_11_words = [
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
"abandon", "abandon", "abandon", "abandon", "abandon"
]

valid_words_12 = mnemonic_generation.get_valid_final_mnemonic_words(partial_11_words)

# there should be exactly 128 valid final words for a 12-word seed
assert len(valid_words_12) == 128, f"Expected 128 valid words for 12-word seed, got {len(valid_words_12)}"

for word in valid_words_12:
test_mnemonic = partial_11_words + [word]
assert bip39.mnemonic_is_valid(" ".join(test_mnemonic)), f"Word '{word}' should create valid mnemonic"

partial_23_words = [
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
"abandon", "abandon", "abandon", "abandon", "abandon"
]

valid_words_24 = mnemonic_generation.get_valid_final_mnemonic_words(partial_23_words)

# there should be exactly 8 valid final words for a 24-word seed
assert len(valid_words_24) == 8, f"Expected 8 valid words for 24-word seed, got {len(valid_words_24)}"

for word in valid_words_24: # Test all since there should be only ~8
test_mnemonic = partial_23_words + [word]
assert bip39.mnemonic_is_valid(" ".join(test_mnemonic)), f"Word '{word}' should create valid mnemonic"