mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 23:38:49 +00:00
feat: add configurable prompt backoff
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
@@ -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.")
|
||||||
|
@@ -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")
|
||||||
|
|
||||||
|
@@ -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)
|
||||||
|
Reference in New Issue
Block a user