mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 07:18:47 +00:00
216 lines
6.7 KiB
Python
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
|