Files
seedPass/src/utils/seed_prompt.py
2025-08-22 21:50:04 -04:00

216 lines
6.7 KiB
Python

import os
import sys
import time
try:
import msvcrt # type: ignore
except ImportError: # pragma: no cover - Windows only
msvcrt = None # type: ignore
try:
import termios
import tty
except ImportError: # pragma: no cover - POSIX only
termios = None # type: ignore
tty = None # type: ignore
from utils.terminal_utils import clear_screen
from utils.logging_utils import pause_console_logging, resume_console_logging
DEFAULT_MAX_ATTEMPTS = 5
def _get_max_attempts(override: int | None = None) -> int:
"""Return the configured maximum number of attempts.
``override`` takes precedence, followed by the
``SEEDPASS_MAX_PROMPT_ATTEMPTS`` environment variable. A value of ``0``
disables the limit entirely.
"""
if override is not None:
return override
env = os.getenv("SEEDPASS_MAX_PROMPT_ATTEMPTS")
if env is not None:
try:
return int(env)
except ValueError:
pass
return DEFAULT_MAX_ATTEMPTS
def _apply_backoff(attempts: int, max_attempts: int) -> None:
"""Sleep using exponential backoff unless the limit is disabled."""
if max_attempts == 0:
return
delay = 2 ** (attempts - 1)
time.sleep(delay)
def _masked_input_windows(prompt: str) -> str:
"""Windows implementation using ``msvcrt``."""
if msvcrt is None: # pragma: no cover - should not happen
return input(prompt)
sys.stdout.write(prompt)
sys.stdout.flush()
buffer: list[str] = []
while True:
ch = msvcrt.getwch()
if ch == "\x03":
raise KeyboardInterrupt
if ch in ("\r", "\n"):
sys.stdout.write("\n")
return "".join(buffer)
if ch in ("\b", "\x7f"):
if buffer:
buffer.pop()
sys.stdout.write("\b \b")
else:
buffer.append(ch)
sys.stdout.write("*")
sys.stdout.flush()
def _masked_input_posix(prompt: str) -> str:
"""POSIX implementation using ``termios`` and ``tty``."""
if termios is None or tty is None: # pragma: no cover - should not happen
return input(prompt)
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
sys.stdout.write(prompt)
sys.stdout.flush()
buffer: list[str] = []
try:
tty.setraw(fd)
while True:
ch = sys.stdin.read(1)
if ch == "\x03":
raise KeyboardInterrupt
if ch in ("\r", "\n"):
sys.stdout.write("\n")
return "".join(buffer)
if ch in ("\x7f", "\b"):
if buffer:
buffer.pop()
sys.stdout.write("\b \b")
else:
buffer.append(ch)
sys.stdout.write("*")
sys.stdout.flush()
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
def masked_input(prompt: str) -> str:
"""Return input from the user while masking typed characters."""
func = _masked_input_windows if sys.platform == "win32" else _masked_input_posix
pause_console_logging()
try:
return func(prompt)
except KeyboardInterrupt:
raise
except Exception: # pragma: no cover - fallback when TTY operations fail
return input(prompt)
finally:
resume_console_logging()
def prompt_seed_words(count: int = 12, *, max_attempts: int | None = None) -> 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``.
max_attempts:
Maximum number of invalid attempts before aborting. ``0`` disables the
limit. Defaults to the ``SEEDPASS_MAX_PROMPT_ATTEMPTS`` environment
variable or ``5`` if unset.
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
max_attempts = _get_max_attempts(max_attempts)
attempts = 0
idx = 0
while idx < count:
clear_screen()
progress = [f"{i+1}: {'*' if w else '_'}" for i, w in enumerate(words)]
print("\n".join(progress))
entered = masked_input(f"Enter word number {idx+1}: ").strip().lower()
if entered not in m.wordlist:
print("Invalid word, try again.")
attempts += 1
if max_attempts != 0 and attempts >= max_attempts:
raise ValueError("Maximum seed prompt attempts exceeded")
_apply_backoff(attempts, max_attempts)
continue
words[idx] = entered
idx += 1
for i in range(count):
while True:
clear_screen()
progress = [f"{j+1}: {'*' if j < i else '_'}" for j in range(count)]
print("\n".join(progress))
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:
clear_screen()
progress = [f"{j+1}: {'*' if j < i else '_'}" for j in range(count)]
print("\n".join(progress))
new_word = (
masked_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.")
attempts += 1
if max_attempts != 0 and attempts >= max_attempts:
raise ValueError("Maximum seed prompt attempts exceeded")
_apply_backoff(attempts, max_attempts)
# Ask for confirmation again with the new word
else:
print("Please respond with 'Y' or 'N'.")
attempts += 1
if max_attempts != 0 and attempts >= max_attempts:
raise ValueError("Maximum seed prompt attempts exceeded")
_apply_backoff(attempts, max_attempts)
continue
phrase = " ".join(words)
if not m.check(phrase):
raise ValueError("Invalid BIP-39 seed phrase")
return phrase