Files
seedPass/src/utils/password_prompt.py
2025-08-20 17:23:10 -04:00

269 lines
9.2 KiB
Python

# utils/password_prompt.py
"""
Password Prompt Module
This module provides functions to securely prompt users for passwords, ensuring that passwords
are entered and confirmed correctly. It handles both the creation of new passwords and the
input of existing passwords for decryption purposes. By centralizing password prompting logic,
this module enhances code reuse, security, and maintainability across the application.
Ensure that all dependencies are installed and properly configured in your environment.
"""
from utils.seed_prompt import masked_input
import logging
import os
import sys
import time
import unicodedata
from termcolor import colored
from colorama import init as colorama_init
from constants import MIN_PASSWORD_LENGTH
# Initialize colorama for colored terminal text
colorama_init()
# Instantiate the logger
logger = logging.getLogger(__name__)
DEFAULT_MAX_ATTEMPTS = 5
def _env_password() -> str | None:
"""Return a password supplied via environment for non-interactive use."""
return os.getenv("SEEDPASS_TEST_PASSWORD") or os.getenv("SEEDPASS_PASSWORD")
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(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 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.
Raises:
PasswordPromptError: If the user fails to provide a valid password after multiple attempts.
"""
env_pw = _env_password()
if env_pw:
normalized = unicodedata.normalize("NFKD", env_pw)
if len(normalized) < MIN_PASSWORD_LENGTH:
raise PasswordPromptError("Environment password too short")
return normalized
max_retries = _get_max_attempts(max_retries)
attempts = 0
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()
if not password:
print(
colored("Error: Password cannot be empty. Please try again.", "red")
)
logging.warning("User attempted to enter an empty password.")
attempts += 1
_apply_backoff(attempts, max_retries)
continue
if len(password) < MIN_PASSWORD_LENGTH:
print(
colored(
f"Error: Password must be at least {MIN_PASSWORD_LENGTH} characters long.",
"red",
)
)
logging.warning(
f"User entered a password shorter than {MIN_PASSWORD_LENGTH} characters."
)
attempts += 1
_apply_backoff(attempts, max_retries)
continue
if password != confirm_password:
print(
colored("Error: Passwords do not match. Please try again.", "red")
)
logging.warning("User entered mismatching passwords.")
attempts += 1
_apply_backoff(attempts, max_retries)
continue
# Normalize the password to NFKD form
normalized_password = unicodedata.normalize("NFKD", password)
logging.debug("User entered a valid and confirmed password.")
return normalized_password
except KeyboardInterrupt:
print(colored("\nOperation cancelled by user.", "yellow"))
logging.info("Password prompt interrupted by user.")
raise PasswordPromptError("Operation cancelled by user")
except Exception as e:
logging.error(
f"Unexpected error during password prompt: {e}", exc_info=True
)
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.")
raise PasswordPromptError("Maximum password attempts exceeded")
def prompt_existing_password(
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 with
an exponential backoff between attempts.
Parameters:
prompt_message (str): Message displayed when prompting for the password.
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.
Raises:
PasswordPromptError: If the user interrupts the operation or exceeds
``max_retries`` attempts.
"""
env_pw = _env_password()
if env_pw:
return unicodedata.normalize("NFKD", env_pw)
max_retries = _get_max_attempts(max_retries)
attempts = 0
while max_retries == 0 or attempts < max_retries:
try:
password = masked_input(prompt_message).strip()
if not password:
print(
colored("Error: Password cannot be empty. Please try again.", "red")
)
logging.warning("User attempted to enter an empty password.")
attempts += 1
_apply_backoff(attempts, max_retries)
continue
normalized_password = unicodedata.normalize("NFKD", password)
logging.debug("User entered an existing password for decryption.")
return normalized_password
except KeyboardInterrupt:
print(colored("\nOperation cancelled by user.", "yellow"))
logging.info("Existing password prompt interrupted by user.")
raise PasswordPromptError("Operation cancelled by user")
except Exception as e:
logging.error(
f"Unexpected error during existing password prompt: {e}",
exc_info=True,
)
print(colored(f"Error: {e}", "red"))
attempts += 1
_apply_backoff(attempts, max_retries)
raise PasswordPromptError("Maximum password attempts exceeded")
def confirm_action(
prompt_message: str = "Are you sure you want to proceed? (Y/N): ",
) -> bool:
"""
Prompts the user to confirm an action, typically used before performing critical operations.
Parameters:
prompt_message (str): The confirmation message displayed to the user. Defaults to
"Are you sure you want to proceed? (Y/N): ".
Returns:
bool: True if the user confirms the action, False otherwise.
Raises:
PasswordPromptError: If the user interrupts the operation.
"""
try:
while True:
response = input(colored(prompt_message, "cyan")).strip().lower()
if response in ["y", "yes"]:
logging.debug("User confirmed the action.")
return True
elif response in ["n", "no"]:
logging.debug("User declined the action.")
return False
else:
print(colored("Please respond with 'Y' or 'N'.", "yellow"))
except KeyboardInterrupt:
print(colored("\nOperation cancelled by user.", "yellow"))
logging.info("Action confirmation interrupted by user.")
raise PasswordPromptError("Operation cancelled by user")
except Exception as e:
logging.error(
f"Unexpected error during action confirmation: {e}", exc_info=True
)
print(colored(f"Error: {e}", "red"))
raise PasswordPromptError(str(e))
def prompt_for_password() -> str:
"""
Prompts the user to enter a new password by invoking the prompt_new_password function.
This function serves as an alias to maintain consistency with import statements in other modules.
Returns:
str: The confirmed password entered by the user.
"""
return prompt_new_password()