feat: add configurable prompt backoff

This commit is contained in:
thePR0M3TH3AN
2025-08-05 22:48:18 -04:00
parent 7725701b50
commit 099c24921f
4 changed files with 113 additions and 9 deletions

View File

@@ -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. - **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. - **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. - **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 ### Secure Deployment

View File

@@ -1239,6 +1239,12 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
action="store_true", action="store_true",
help="Disable clipboard support and print secrets", 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") sub = parser.add_subparsers(dest="command")
exp = sub.add_parser("export") 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) 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: try:
password_manager = PasswordManager(fingerprint=args.fingerprint or fingerprint) password_manager = PasswordManager(fingerprint=args.fingerprint or fingerprint)
logger.info("PasswordManager initialized successfully.") logger.info("PasswordManager initialized successfully.")

View File

@@ -13,7 +13,9 @@ Ensure that all dependencies are installed and properly configured in your envir
from utils.seed_prompt import masked_input from utils.seed_prompt import masked_input
import logging import logging
import os
import sys import sys
import time
import unicodedata import unicodedata
from termcolor import colored from termcolor import colored
@@ -28,18 +30,49 @@ colorama_init()
logger = logging.getLogger(__name__) 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): class PasswordPromptError(Exception):
"""Exception raised for password prompt errors.""" """Exception raised for password prompt errors."""
pass 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. 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 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: Returns:
str: The confirmed password entered by the user. str: The confirmed password entered by the user.
@@ -47,10 +80,10 @@ def prompt_new_password() -> str:
Raises: Raises:
PasswordPromptError: If the user fails to provide a valid password after multiple attempts. 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 attempts = 0
while attempts < max_retries: while max_retries == 0 or attempts < max_retries:
try: try:
password = masked_input("Enter a new password: ").strip() password = masked_input("Enter a new password: ").strip()
confirm_password = masked_input("Confirm your 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.") logging.warning("User attempted to enter an empty password.")
attempts += 1 attempts += 1
_apply_backoff(attempts, max_retries)
continue continue
if len(password) < MIN_PASSWORD_LENGTH: 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." f"User entered a password shorter than {MIN_PASSWORD_LENGTH} characters."
) )
attempts += 1 attempts += 1
_apply_backoff(attempts, max_retries)
continue continue
if password != confirm_password: if password != confirm_password:
@@ -82,6 +117,7 @@ def prompt_new_password() -> str:
) )
logging.warning("User entered mismatching passwords.") logging.warning("User entered mismatching passwords.")
attempts += 1 attempts += 1
_apply_backoff(attempts, max_retries)
continue continue
# Normalize the password to NFKD form # Normalize the password to NFKD form
@@ -99,6 +135,7 @@ def prompt_new_password() -> str:
) )
print(colored(f"Error: {e}", "red")) print(colored(f"Error: {e}", "red"))
attempts += 1 attempts += 1
_apply_backoff(attempts, max_retries)
print(colored("Maximum password attempts exceeded. Exiting.", "red")) print(colored("Maximum password attempts exceeded. Exiting.", "red"))
logging.error("User failed to provide a valid password after multiple attempts.") 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( 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: ) -> str:
""" """
Prompt the user for an existing password. 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: Parameters:
prompt_message (str): Message displayed when prompting for the password. 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: Returns:
str: The password provided by the user. str: The password provided by the user.
@@ -124,8 +164,9 @@ def prompt_existing_password(
PasswordPromptError: If the user interrupts the operation or exceeds PasswordPromptError: If the user interrupts the operation or exceeds
``max_retries`` attempts. ``max_retries`` attempts.
""" """
max_retries = _get_max_attempts(max_retries)
attempts = 0 attempts = 0
while attempts < max_retries: while max_retries == 0 or attempts < max_retries:
try: try:
password = masked_input(prompt_message).strip() password = masked_input(prompt_message).strip()
@@ -135,6 +176,7 @@ def prompt_existing_password(
) )
logging.warning("User attempted to enter an empty password.") logging.warning("User attempted to enter an empty password.")
attempts += 1 attempts += 1
_apply_backoff(attempts, max_retries)
continue continue
normalized_password = unicodedata.normalize("NFKD", password) normalized_password = unicodedata.normalize("NFKD", password)
@@ -152,6 +194,7 @@ def prompt_existing_password(
) )
print(colored(f"Error: {e}", "red")) print(colored(f"Error: {e}", "red"))
attempts += 1 attempts += 1
_apply_backoff(attempts, max_retries)
raise PasswordPromptError("Maximum password attempts exceeded") raise PasswordPromptError("Maximum password attempts exceeded")

View File

@@ -1,5 +1,6 @@
import os import os
import sys import sys
import time
try: try:
import msvcrt # type: ignore import msvcrt # type: ignore
@@ -16,6 +17,37 @@ except ImportError: # pragma: no cover - POSIX only
from utils.terminal_utils import clear_screen 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: def _masked_input_windows(prompt: str) -> str:
"""Windows implementation using ``msvcrt``.""" """Windows implementation using ``msvcrt``."""
if msvcrt is None: # pragma: no cover - should not happen 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) 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. """Prompt the user for a BIP-39 seed phrase.
The user is asked for each word one at a time. A numbered list is 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: count:
Number of words to prompt for. Defaults to ``12``. 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 Returns
------- -------
@@ -105,6 +141,9 @@ def prompt_seed_words(count: int = 12) -> str:
m = Mnemonic("english") m = Mnemonic("english")
words: list[str] = [""] * count words: list[str] = [""] * count
max_attempts = _get_max_attempts(max_attempts)
attempts = 0
idx = 0 idx = 0
while idx < count: while idx < count:
clear_screen() 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() entered = masked_input(f"Enter word number {idx+1}: ").strip().lower()
if entered not in m.wordlist: if entered not in m.wordlist:
print("Invalid word, try again.") 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 continue
words[idx] = entered words[idx] = entered
idx += 1 idx += 1
@@ -141,9 +184,17 @@ def prompt_seed_words(count: int = 12) -> str:
words[i] = new_word words[i] = new_word
break break
print("Invalid word, try again.") 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 # Ask for confirmation again with the new word
else: else:
print("Please respond with 'Y' or 'N'.") 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 continue
phrase = " ".join(words) phrase = " ".join(words)