diff --git a/src/tests/test_seed_prompt.py b/src/tests/test_seed_prompt.py index 68876d3..44b82e2 100644 --- a/src/tests/test_seed_prompt.py +++ b/src/tests/test_seed_prompt.py @@ -28,3 +28,31 @@ def test_masked_input_windows_space(monkeypatch, capsys): out = capsys.readouterr().out assert out.startswith("Password: ") assert out.count("*") == 4 + + +def test_prompt_seed_words_valid(monkeypatch): + from mnemonic import Mnemonic + + m = Mnemonic("english") + phrase = m.generate(strength=128) + words = phrase.split() + + inputs = iter(words + ["y"] * len(words)) + monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) + + result = seed_prompt.prompt_seed_words(len(words)) + assert result == phrase + + +def test_prompt_seed_words_invalid_word(monkeypatch): + from mnemonic import Mnemonic + + m = Mnemonic("english") + phrase = m.generate(strength=128) + words = phrase.split() + # Insert an invalid word for the first entry then the correct one + inputs = iter(["invalid"] + [words[0]] + words[1:] + ["y"] * len(words)) + monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) + + result = seed_prompt.prompt_seed_words(len(words)) + assert result == phrase diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 1a94070..6e83033 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -25,7 +25,7 @@ try: update_checksum_file, ) from .password_prompt import prompt_for_password - from .seed_prompt import masked_input + from .seed_prompt import masked_input, prompt_seed_words from .input_utils import timed_input from .memory_protection import InMemorySecret from .clipboard import copy_to_clipboard @@ -60,6 +60,7 @@ __all__ = [ "shared_lock", "prompt_for_password", "masked_input", + "prompt_seed_words", "timed_input", "InMemorySecret", "copy_to_clipboard", diff --git a/src/utils/seed_prompt.py b/src/utils/seed_prompt.py index 80ff91f..dd18e0a 100644 --- a/src/utils/seed_prompt.py +++ b/src/utils/seed_prompt.py @@ -71,3 +71,71 @@ def masked_input(prompt: str) -> str: if sys.platform == "win32": return _masked_input_windows(prompt) return _masked_input_posix(prompt) + + +def prompt_seed_words(count: int = 12) -> str: + """Prompt the user for a BIP-39 seed phrase. + + The user is asked for each word one at a time. A numbered list is + displayed showing ``*`` for entered words and ``_`` for words yet to be + provided. After all words are entered the user is asked to confirm each + word individually. If the user answers ``no`` to a confirmation prompt the + word can be re-entered. + + Parameters + ---------- + count: + Number of words to prompt for. Defaults to ``12``. + + Returns + ------- + str + The complete seed phrase. + + Raises + ------ + ValueError + If the resulting phrase fails ``Mnemonic.check`` validation. + """ + + from mnemonic import Mnemonic + + m = Mnemonic("english") + words: list[str] = [""] * count + + idx = 0 + while idx < count: + progress = [f"{i+1}: {'*' if w else '_'}" for i, w in enumerate(words)] + print("\n".join(progress)) + entered = input(f"Enter word number {idx+1}: ").strip().lower() + if entered not in m.wordlist: + print("Invalid word, try again.") + continue + words[idx] = entered + idx += 1 + + for i in range(count): + while True: + response = ( + input(f"Is this the correct word for number {i+1}? {words[i]} (Y/N): ") + .strip() + .lower() + ) + if response in ("y", "yes"): + break + if response in ("n", "no"): + while True: + new_word = input(f"Re-enter word number {i+1}: ").strip().lower() + if new_word in m.wordlist: + words[i] = new_word + break + print("Invalid word, try again.") + # Ask for confirmation again with the new word + else: + print("Please respond with 'Y' or 'N'.") + continue + + phrase = " ".join(words) + if not m.check(phrase): + raise ValueError("Invalid BIP-39 seed phrase") + return phrase