From 099c24921f834b11e1bb74d2a64dac9f64070f4d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 5 Aug 2025 22:48:18 -0400 Subject: [PATCH] feat: add configurable prompt backoff --- README.md | 1 + src/main.py | 9 ++++++ src/utils/password_prompt.py | 59 +++++++++++++++++++++++++++++++----- src/utils/seed_prompt.py | 53 +++++++++++++++++++++++++++++++- 4 files changed, 113 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 26993d1..30cd899 100644 --- a/README.md +++ b/README.md @@ -726,6 +726,7 @@ You can also launch the GUI directly with `seedpass gui` or `seedpass-gui`. - **KDF Iteration Caution:** Lowering `kdf_iterations` makes password cracking easier, while a high `backup_interval` leaves fewer recent backups. - **Offline Mode:** When enabled, SeedPass skips all Nostr operations so your vault stays local until syncing is turned back on. - **Quick Unlock:** Stores a hashed copy of your password in the encrypted config so you only need to enter it once per session. Avoid this on shared computers. +- **Prompt Rate Limiting:** Seed and password prompts enforce a configurable attempt limit with exponential backoff to slow brute-force attacks. Adjust or disable the limit for testing via the `--max-prompt-attempts` CLI option or the `SEEDPASS_MAX_PROMPT_ATTEMPTS` environment variable. ### Secure Deployment diff --git a/src/main.py b/src/main.py index 6ea1428..15eda46 100644 --- a/src/main.py +++ b/src/main.py @@ -1239,6 +1239,12 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in action="store_true", help="Disable clipboard support and print secrets", ) + parser.add_argument( + "--max-prompt-attempts", + type=int, + default=None, + help="Maximum number of password/seed prompt attempts (0 to disable)", + ) sub = parser.add_subparsers(dest="command") exp = sub.add_parser("export") @@ -1258,6 +1264,9 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in args = parser.parse_args(argv) + if args.max_prompt_attempts is not None: + os.environ["SEEDPASS_MAX_PROMPT_ATTEMPTS"] = str(args.max_prompt_attempts) + try: password_manager = PasswordManager(fingerprint=args.fingerprint or fingerprint) logger.info("PasswordManager initialized successfully.") diff --git a/src/utils/password_prompt.py b/src/utils/password_prompt.py index 498e6b5..09cc86c 100644 --- a/src/utils/password_prompt.py +++ b/src/utils/password_prompt.py @@ -13,7 +13,9 @@ Ensure that all dependencies are installed and properly configured in your envir from utils.seed_prompt import masked_input import logging +import os import sys +import time import unicodedata from termcolor import colored @@ -28,18 +30,49 @@ colorama_init() logger = logging.getLogger(__name__) +DEFAULT_MAX_ATTEMPTS = 5 + + +def _get_max_attempts(override: int | None = None) -> int: + """Return the configured maximum number of prompt attempts.""" + + 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 disabled.""" + + if max_attempts == 0: + return + delay = 2 ** (attempts - 1) + time.sleep(delay) + + class PasswordPromptError(Exception): """Exception raised for password prompt errors.""" pass -def prompt_new_password() -> str: +def prompt_new_password(max_retries: int | None = None) -> str: """ Prompts the user to enter and confirm a new password for encrypting the parent seed. This function ensures that the password meets the minimum length requirement and that the - password and confirmation match. It provides user-friendly messages and handles retries. + password and confirmation match. It provides user-friendly messages and handles retries with + an exponential backoff between attempts. + + Parameters: + max_retries (int | None): Maximum number of attempts before aborting. ``0`` disables the + limit. Defaults to the ``SEEDPASS_MAX_PROMPT_ATTEMPTS`` environment variable or ``5``. Returns: str: The confirmed password entered by the user. @@ -47,10 +80,10 @@ def prompt_new_password() -> str: Raises: PasswordPromptError: If the user fails to provide a valid password after multiple attempts. """ - max_retries = 5 + max_retries = _get_max_attempts(max_retries) attempts = 0 - while attempts < max_retries: + while max_retries == 0 or attempts < max_retries: try: password = masked_input("Enter a new password: ").strip() confirm_password = masked_input("Confirm your password: ").strip() @@ -61,6 +94,7 @@ def prompt_new_password() -> str: ) logging.warning("User attempted to enter an empty password.") attempts += 1 + _apply_backoff(attempts, max_retries) continue if len(password) < MIN_PASSWORD_LENGTH: @@ -74,6 +108,7 @@ def prompt_new_password() -> str: f"User entered a password shorter than {MIN_PASSWORD_LENGTH} characters." ) attempts += 1 + _apply_backoff(attempts, max_retries) continue if password != confirm_password: @@ -82,6 +117,7 @@ def prompt_new_password() -> str: ) logging.warning("User entered mismatching passwords.") attempts += 1 + _apply_backoff(attempts, max_retries) continue # Normalize the password to NFKD form @@ -99,6 +135,7 @@ def prompt_new_password() -> str: ) print(colored(f"Error: {e}", "red")) attempts += 1 + _apply_backoff(attempts, max_retries) print(colored("Maximum password attempts exceeded. Exiting.", "red")) logging.error("User failed to provide a valid password after multiple attempts.") @@ -106,16 +143,19 @@ def prompt_new_password() -> str: def prompt_existing_password( - prompt_message: str = "Enter your password: ", max_retries: int = 5 + prompt_message: str = "Enter your password: ", max_retries: int | None = None ) -> str: """ Prompt the user for an existing password. - The user will be reprompted on empty input up to ``max_retries`` times. + The user will be reprompted on empty input up to ``max_retries`` times with + an exponential backoff between attempts. Parameters: prompt_message (str): Message displayed when prompting for the password. - max_retries (int): Number of attempts allowed before aborting. + max_retries (int | None): Number of attempts allowed before aborting. ``0`` + disables the limit. Defaults to the ``SEEDPASS_MAX_PROMPT_ATTEMPTS`` + environment variable or ``5``. Returns: str: The password provided by the user. @@ -124,8 +164,9 @@ def prompt_existing_password( PasswordPromptError: If the user interrupts the operation or exceeds ``max_retries`` attempts. """ + max_retries = _get_max_attempts(max_retries) attempts = 0 - while attempts < max_retries: + while max_retries == 0 or attempts < max_retries: try: password = masked_input(prompt_message).strip() @@ -135,6 +176,7 @@ def prompt_existing_password( ) logging.warning("User attempted to enter an empty password.") attempts += 1 + _apply_backoff(attempts, max_retries) continue normalized_password = unicodedata.normalize("NFKD", password) @@ -152,6 +194,7 @@ def prompt_existing_password( ) print(colored(f"Error: {e}", "red")) attempts += 1 + _apply_backoff(attempts, max_retries) raise PasswordPromptError("Maximum password attempts exceeded") diff --git a/src/utils/seed_prompt.py b/src/utils/seed_prompt.py index 7c83977..af806ca 100644 --- a/src/utils/seed_prompt.py +++ b/src/utils/seed_prompt.py @@ -1,5 +1,6 @@ import os import sys +import time try: import msvcrt # type: ignore @@ -16,6 +17,37 @@ except ImportError: # pragma: no cover - POSIX only from utils.terminal_utils import clear_screen +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 @@ -75,7 +107,7 @@ def masked_input(prompt: str) -> str: return _masked_input_posix(prompt) -def prompt_seed_words(count: int = 12) -> str: +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 @@ -88,6 +120,10 @@ def prompt_seed_words(count: int = 12) -> str: ---------- 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 ------- @@ -105,6 +141,9 @@ def prompt_seed_words(count: int = 12) -> str: m = Mnemonic("english") words: list[str] = [""] * count + max_attempts = _get_max_attempts(max_attempts) + attempts = 0 + idx = 0 while idx < count: clear_screen() @@ -113,6 +152,10 @@ def prompt_seed_words(count: int = 12) -> str: 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 @@ -141,9 +184,17 @@ def prompt_seed_words(count: int = 12) -> str: 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)