diff --git a/README.md b/README.md index a4d9e50..c86c184 100644 --- a/README.md +++ b/README.md @@ -369,8 +369,9 @@ SeedPass allows you to manage multiple seed profiles (previously referred to as - **Add a New Seed Profile:** 1. From the main menu, select **Settings** then **Profiles** and choose "Add a New Seed Profile". - 2. Choose to enter an existing seed or generate a new one. - 3. If generating a new seed, you'll be provided with a 12-word BIP-85 seed phrase. **Ensure you write this down and store it securely.** + 2. Choose to paste in a full seed, enter one word at a time, or generate a new seed. + 3. If you enter the seed word by word, each word is hidden with `*` and the screen refreshes after every entry for clarity. SeedPass then shows the completed phrase for confirmation so you can fix any mistakes before it is stored. + 4. If generating a new seed, you'll be provided with a 12-word BIP-85 seed phrase. **Ensure you write this down and store it securely.** - **Switch Between Seed Profiles:** 1. From the **Profiles** menu, select "Switch Seed Profile". @@ -378,8 +379,12 @@ SeedPass allows you to manage multiple seed profiles (previously referred to as 3. Enter the number corresponding to the seed profile you wish to switch to. 4. Enter the master password associated with that seed profile. -- **List All Seed Profiles:** +- **List All Seed Profiles:** In the **Profiles** menu, choose "List All Seed Profiles" to view all existing profiles. +- **Set Seed Profile Name:** + In the **Profiles** menu, choose "Set Seed Profile Name" to assign an optional + label to the currently selected profile. The name is stored locally and shown + alongside the fingerprint in menus. **Note:** The term "seed profile" is used to represent different sets of seeds you can manage within SeedPass. This provides an intuitive way to handle multiple identities or sets of passwords. diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index 09b38fd..d417824 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -357,7 +357,8 @@ SeedPass allows you to manage multiple seed profiles (previously referred to as - **Add a New Seed Profile:** - From the main menu, select **Settings** then **Profiles** and choose "Add a New Seed Profile". - - Choose to enter an existing seed or generate a new one. + - Choose to paste in a full seed, enter one word at a time, or generate a new seed. + - When entering a seed word by word, each word is hidden with `*` and the screen refreshes after every entry for clarity. You'll review the completed phrase after the last word and can correct mistakes before it is saved. - If generating a new seed, you'll be provided with a 12-word BIP-85 seed phrase. **Ensure you write this down and store it securely.** - **Switch Between Seed Profiles:** @@ -368,6 +369,8 @@ SeedPass allows you to manage multiple seed profiles (previously referred to as - **List All Seed Profiles:** - In the **Profiles** menu, choose "List All Seed Profiles" to view all existing profiles. +- **Set Seed Profile Name:** + - In the **Profiles** menu, choose "Set Seed Profile Name" to assign a label to the current profile. The name is stored locally and shown next to the fingerprint. **Note:** The term "seed profile" is used to represent different sets of seeds you can manage within SeedPass. This provides an intuitive way to handle multiple identities or sets of passwords. diff --git a/src/main.py b/src/main.py index 1751c3f..565ade0 100644 --- a/src/main.py +++ b/src/main.py @@ -151,7 +151,8 @@ def handle_switch_fingerprint(password_manager: PasswordManager): print(colored("Available Seed Profiles:", "cyan")) for idx, fp in enumerate(fingerprints, start=1): - print(colored(f"{idx}. {fp}", "cyan")) + label = password_manager.fingerprint_manager.display_name(fp) + print(colored(f"{idx}. {label}", "cyan")) choice = input("Select a seed profile by number to switch: ").strip() if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)): @@ -195,7 +196,8 @@ def handle_remove_fingerprint(password_manager: PasswordManager): print(colored("Available Seed Profiles:", "cyan")) for idx, fp in enumerate(fingerprints, start=1): - print(colored(f"{idx}. {fp}", "cyan")) + label = password_manager.fingerprint_manager.display_name(fp) + print(colored(f"{idx}. {label}", "cyan")) choice = input("Select a seed profile by number to remove: ").strip() if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)): @@ -239,7 +241,8 @@ def handle_list_fingerprints(password_manager: PasswordManager): print(colored("Available Seed Profiles:", "cyan")) for fp in fingerprints: - print(colored(f"- {fp}", "cyan")) + label = password_manager.fingerprint_manager.display_name(fp) + print(colored(f"- {label}", "cyan")) pause() except Exception as e: logging.error(f"Error listing seed profiles: {e}", exc_info=True) @@ -641,6 +644,25 @@ def handle_set_additional_backup_location(pm: PasswordManager) -> None: print(colored(f"Error: {e}", "red")) +def handle_set_profile_name(pm: PasswordManager) -> None: + """Set or clear the custom name for the current seed profile.""" + fp = getattr(pm.fingerprint_manager, "current_fingerprint", None) + if not fp: + print(colored("No seed profile selected.", "red")) + return + current = pm.fingerprint_manager.get_name(fp) + if current: + print(colored(f"Current name: {current}", "cyan")) + else: + print(colored("No custom name set.", "cyan")) + value = input("Enter new name (leave blank to remove): ").strip() + if pm.fingerprint_manager.set_name(fp, value or None): + if value: + print(colored("Name updated.", "green")) + else: + print(colored("Name removed.", "green")) + + def handle_toggle_secret_mode(pm: PasswordManager) -> None: """Toggle secret mode and adjust clipboard delay.""" cfg = pm.config_manager @@ -756,6 +778,7 @@ def handle_profiles_menu(password_manager: PasswordManager) -> None: print(color_text("2. Add a New Seed Profile", "menu")) print(color_text("3. Remove an Existing Seed Profile", "menu")) print(color_text("4. List All Seed Profiles", "menu")) + print(color_text("5. Set Seed Profile Name", "menu")) choice = input("Select an option or press Enter to go back: ").strip() password_manager.update_activity() if choice == "1": @@ -767,6 +790,8 @@ def handle_profiles_menu(password_manager: PasswordManager) -> None: handle_remove_fingerprint(password_manager) elif choice == "4": handle_list_fingerprints(password_manager) + elif choice == "5": + handle_set_profile_name(password_manager) elif not choice: break else: diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index b2a2452..8b8ca8a 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -15,7 +15,7 @@ import logging import getpass import os import hashlib -from typing import Optional +from typing import Optional, Literal import shutil import time import builtins @@ -54,6 +54,7 @@ from utils.password_prompt import ( prompt_new_password, confirm_action, ) +from utils import masked_input, prompt_seed_words from utils.memory_protection import InMemorySecret from utils.clipboard import copy_to_clipboard from utils.terminal_utils import ( @@ -87,6 +88,7 @@ from pathlib import Path from local_bip85.bip85 import BIP85, Bip85Error from bip_utils import Bip39SeedGenerator, Bip39MnemonicGenerator, Bip39Languages +from mnemonic import Mnemonic from datetime import datetime from utils.fingerprint_manager import FingerprintManager @@ -329,8 +331,13 @@ class PasswordManager: print(colored("\nAvailable Seed Profiles:", "cyan")) for idx, fp in enumerate(fingerprints, start=1): + label = ( + self.fingerprint_manager.display_name(fp) + if hasattr(self.fingerprint_manager, "display_name") + else fp + ) marker = " *" if fp == current else "" - print(colored(f"{idx}. {fp}{marker}", "cyan")) + print(colored(f"{idx}. {label}{marker}", "cyan")) print(colored(f"{len(fingerprints)+1}. Add a new seed profile", "cyan")) @@ -360,11 +367,15 @@ class PasswordManager: """ try: choice = input( - "Do you want to (1) Enter an existing seed or (2) Generate a new seed? (1/2): " + "Do you want to (1) Paste in an existing seed in full " + "(2) Enter an existing seed one word at a time or " + "(3) Generate a new seed? (1/2/3): " ).strip() if choice == "1": - fingerprint = self.setup_existing_seed() + fingerprint = self.setup_existing_seed(method="paste") elif choice == "2": + fingerprint = self.setup_existing_seed(method="words") + elif choice == "3": fingerprint = self.generate_new_seed() else: print(colored("Invalid choice. Exiting.", "red")) @@ -532,7 +543,12 @@ class PasswordManager: print(colored("\nAvailable Seed Profiles:", "cyan")) fingerprints = self.fingerprint_manager.list_fingerprints() for idx, fp in enumerate(fingerprints, start=1): - print(colored(f"{idx}. {fp}", "cyan")) + display = ( + self.fingerprint_manager.display_name(fp) + if hasattr(self.fingerprint_manager, "display_name") + else fp + ) + print(colored(f"{idx}. {display}", "cyan")) choice = input("Select a seed profile by number to switch: ").strip() if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)): @@ -680,7 +696,12 @@ class PasswordManager: print(colored("Available Seed Profiles:", "cyan")) for idx, fp in enumerate(fingerprints, start=1): - print(colored(f"{idx}. {fp}", "cyan")) + label = ( + self.fingerprint_manager.display_name(fp) + if hasattr(self.fingerprint_manager, "display_name") + else fp + ) + print(colored(f"{idx}. {label}", "cyan")) choice = input("Select a seed profile by number: ").strip() if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)): @@ -727,110 +748,127 @@ class PasswordManager: self.notify("No existing seed found. Let's set up a new one!", level="WARNING") choice = input( - "Do you want to (1) Enter an existing BIP-85 seed or (2) Generate a new BIP-85 seed? (1/2): " + "Do you want to (1) Paste in an existing seed in full " + "(2) Enter an existing seed one word at a time or " + "(3) Generate a new seed? (1/2/3): " ).strip() if choice == "1": - self.setup_existing_seed() + self.setup_existing_seed(method="paste") elif choice == "2": + self.setup_existing_seed(method="words") + elif choice == "3": self.generate_new_seed() else: print(colored("Invalid choice. Exiting.", "red")) sys.exit(1) - def setup_existing_seed(self) -> Optional[str]: - """ - Prompts the user to enter an existing BIP-85 seed and validates it. + def setup_existing_seed( + self, method: Literal["paste", "words"] = "paste" + ) -> Optional[str]: + """Prompt for an existing BIP-85 seed and set it up. - Returns: - Optional[str]: The fingerprint if setup is successful, None otherwise. + Parameters + ---------- + method: + ``"paste"`` to enter the entire phrase at once or ``"words"`` to + be prompted one word at a time. + + Returns + ------- + Optional[str] + The fingerprint if setup is successful, ``None`` otherwise. """ try: - parent_seed = getpass.getpass( - prompt="Enter your 12-word BIP-85 seed: " - ).strip() - if self.validate_bip85_seed(parent_seed): - # Add a fingerprint using the existing seed - fingerprint = self.fingerprint_manager.add_fingerprint(parent_seed) - if not fingerprint: - print( - colored( - "Error: Failed to generate seed profile for the provided seed.", - "red", - ) - ) - sys.exit(1) - - fingerprint_dir = self.fingerprint_manager.get_fingerprint_directory( - fingerprint - ) - if not fingerprint_dir: - print( - colored( - "Error: Failed to retrieve seed profile directory.", "red" - ) - ) - sys.exit(1) - - # Set the current fingerprint in both PasswordManager and FingerprintManager - self.current_fingerprint = fingerprint - self.fingerprint_manager.current_fingerprint = fingerprint - self.fingerprint_dir = fingerprint_dir - logging.info(f"Current seed profile set to {fingerprint}") - - try: - # Initialize EncryptionManager with key and fingerprint_dir - password = prompt_for_password() - index_key = derive_index_key(parent_seed) - iterations = ( - self.config_manager.get_kdf_iterations() - if getattr(self, "config_manager", None) - else 50_000 - ) - seed_key = derive_key_from_password(password, iterations=iterations) - - self.encryption_manager = EncryptionManager( - index_key, fingerprint_dir - ) - seed_mgr = EncryptionManager(seed_key, fingerprint_dir) - self.vault = Vault(self.encryption_manager, fingerprint_dir) - - # Ensure config manager is set for the new fingerprint - self.config_manager = ConfigManager( - vault=self.vault, - fingerprint_dir=fingerprint_dir, - ) - - # Encrypt and save the parent seed - seed_mgr.encrypt_parent_seed(parent_seed) - logging.info("Parent seed encrypted and saved successfully.") - - # Store the hashed password - self.store_hashed_password(password) - logging.info("User password hashed and stored successfully.") - - self.parent_seed = parent_seed # Ensure this is a string - logger.debug( - f"parent_seed set to: {self.parent_seed} (type: {type(self.parent_seed)})" - ) - - self.initialize_bip85() - self.initialize_managers() - self.start_background_sync() - return fingerprint # Return the generated or added fingerprint - except BaseException: - # Clean up partial profile on failure or interruption - self.fingerprint_manager.remove_fingerprint(fingerprint) - raise + if method == "words": + parent_seed = prompt_seed_words() else: + parent_seed = masked_input("Enter your 12-word BIP-85 seed: ").strip() + + if not self.validate_bip85_seed(parent_seed): logging.error("Invalid BIP-85 seed phrase. Exiting.") print(colored("Error: Invalid BIP-85 seed phrase.", "red")) sys.exit(1) + + return self._finalize_existing_seed(parent_seed) except KeyboardInterrupt: logging.info("Operation cancelled by user.") self.notify("Operation cancelled by user.", level="WARNING") sys.exit(0) + def setup_existing_seed_word_by_word(self) -> Optional[str]: + """Prompt for an existing seed one word at a time and set it up.""" + return self.setup_existing_seed(method="words") + + def _finalize_existing_seed(self, parent_seed: str) -> Optional[str]: + """Common logic for initializing an existing seed.""" + if self.validate_bip85_seed(parent_seed): + fingerprint = self.fingerprint_manager.add_fingerprint(parent_seed) + if not fingerprint: + print( + colored( + "Error: Failed to generate seed profile for the provided seed.", + "red", + ) + ) + sys.exit(1) + + fingerprint_dir = self.fingerprint_manager.get_fingerprint_directory( + fingerprint + ) + if not fingerprint_dir: + print( + colored("Error: Failed to retrieve seed profile directory.", "red") + ) + sys.exit(1) + + self.current_fingerprint = fingerprint + self.fingerprint_manager.current_fingerprint = fingerprint + self.fingerprint_dir = fingerprint_dir + logging.info(f"Current seed profile set to {fingerprint}") + + try: + password = prompt_for_password() + index_key = derive_index_key(parent_seed) + iterations = ( + self.config_manager.get_kdf_iterations() + if getattr(self, "config_manager", None) + else 50_000 + ) + seed_key = derive_key_from_password(password, iterations=iterations) + + self.encryption_manager = EncryptionManager(index_key, fingerprint_dir) + seed_mgr = EncryptionManager(seed_key, fingerprint_dir) + self.vault = Vault(self.encryption_manager, fingerprint_dir) + + self.config_manager = ConfigManager( + vault=self.vault, + fingerprint_dir=fingerprint_dir, + ) + + seed_mgr.encrypt_parent_seed(parent_seed) + logging.info("Parent seed encrypted and saved successfully.") + + self.store_hashed_password(password) + logging.info("User password hashed and stored successfully.") + + self.parent_seed = parent_seed + logger.debug( + f"parent_seed set to: {self.parent_seed} (type: {type(self.parent_seed)})" + ) + + self.initialize_bip85() + self.initialize_managers() + self.start_background_sync() + return fingerprint + except BaseException: + self.fingerprint_manager.remove_fingerprint(fingerprint) + raise + else: + logging.error("Invalid BIP-85 seed phrase. Exiting.") + print(colored("Error: Invalid BIP-85 seed phrase.", "red")) + sys.exit(1) + def generate_new_seed(self) -> Optional[str]: """ Generates a new BIP-85 seed, displays it to the user, and prompts for confirmation before saving. @@ -894,11 +932,11 @@ class PasswordManager: bool: True if valid, False otherwise. """ try: - words = seed.split() - if len(words) != 12: - return False - # Additional validation can be added here if needed (e.g., word list checks) - return True + checker = Mnemonic("english") + if checker.check(seed): + return True + logging.error("Invalid BIP-85 seed provided") + return False except Exception as e: logging.error(f"Error validating BIP-85 seed: {e}") return False diff --git a/src/tests/test_manager_seed_setup.py b/src/tests/test_manager_seed_setup.py new file mode 100644 index 0000000..e8277c9 --- /dev/null +++ b/src/tests/test_manager_seed_setup.py @@ -0,0 +1,65 @@ +import builtins +from mnemonic import Mnemonic +from password_manager.manager import PasswordManager +from utils import seed_prompt + + +def test_validate_bip85_seed_invalid_word(): + pm = PasswordManager.__new__(PasswordManager) + bad_phrase = "abandon " * 11 + "zzzz" + assert not pm.validate_bip85_seed(bad_phrase) + + +def test_validate_bip85_seed_checksum_failure(): + pm = PasswordManager.__new__(PasswordManager) + m = Mnemonic("english") + phrase = m.generate(strength=128) + words = phrase.split() + words[-1] = "abandon" if words[-1] != "abandon" else "about" + bad_phrase = " ".join(words) + assert not pm.validate_bip85_seed(bad_phrase) + + +def test_setup_existing_seed_words(monkeypatch): + m = Mnemonic("english") + phrase = m.generate(strength=128) + words = phrase.split() + word_iter = iter(words) + monkeypatch.setattr( + "password_manager.manager.masked_input", + lambda *_: next(word_iter), + ) + # Ensure prompt_seed_words uses the patched function + monkeypatch.setattr(seed_prompt, "masked_input", lambda *_: next(word_iter)) + monkeypatch.setattr(builtins, "input", lambda *_: "y") + + pm = PasswordManager.__new__(PasswordManager) + monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed: seed) + + result = pm.setup_existing_seed(method="words") + assert result == phrase + + +def test_setup_existing_seed_paste(monkeypatch): + m = Mnemonic("english") + phrase = m.generate(strength=128) + + called = {} + + def fake_masked_input(prompt: str) -> str: + called["prompt"] = prompt + return phrase + + monkeypatch.setattr("password_manager.manager.masked_input", fake_masked_input) + monkeypatch.setattr( + builtins, + "input", + lambda *_: (_ for _ in ()).throw(RuntimeError("input called")), + ) + + pm = PasswordManager.__new__(PasswordManager) + monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed: seed) + + result = pm.setup_existing_seed(method="paste") + assert result == phrase + assert called["prompt"].startswith("Enter your 12-word BIP-85 seed") diff --git a/src/tests/test_profile_management.py b/src/tests/test_profile_management.py index bbb52de..7665b87 100644 --- a/src/tests/test_profile_management.py +++ b/src/tests/test_profile_management.py @@ -41,7 +41,7 @@ def test_add_and_delete_entry(monkeypatch): manager_module.PasswordManager, "generate_bip85_seed", lambda self: seed ) monkeypatch.setattr(manager_module, "confirm_action", lambda *_a, **_k: True) - monkeypatch.setattr("builtins.input", lambda *_a, **_k: "2") + monkeypatch.setattr("builtins.input", lambda *_a, **_k: "3") pm.add_new_fingerprint() diff --git a/src/tests/test_seed_prompt.py b/src/tests/test_seed_prompt.py new file mode 100644 index 0000000..700c03f --- /dev/null +++ b/src/tests/test_seed_prompt.py @@ -0,0 +1,60 @@ +import types +from utils import seed_prompt + + +def test_masked_input_posix_backspace(monkeypatch, capsys): + seq = iter(["a", "b", "\x7f", "c", "\n"]) + monkeypatch.setattr(seed_prompt.sys.stdin, "read", lambda n=1: next(seq)) + monkeypatch.setattr(seed_prompt.sys.stdin, "fileno", lambda: 0) + monkeypatch.setattr(seed_prompt.termios, "tcgetattr", lambda fd: None) + monkeypatch.setattr(seed_prompt.termios, "tcsetattr", lambda fd, *_: None) + monkeypatch.setattr(seed_prompt.tty, "setraw", lambda fd: None) + + result = seed_prompt.masked_input("Enter: ") + assert result == "ac" + out = capsys.readouterr().out + assert out.startswith("Enter: ") + assert out.count("*") == 3 + + +def test_masked_input_windows_space(monkeypatch, capsys): + seq = iter(["x", "y", " ", "z", "\r"]) + fake_msvcrt = types.SimpleNamespace(getwch=lambda: next(seq)) + monkeypatch.setattr(seed_prompt, "msvcrt", fake_msvcrt) + monkeypatch.setattr(seed_prompt.sys, "platform", "win32", raising=False) + + result = seed_prompt.masked_input("Password: ") + assert result == "xy z" + out = capsys.readouterr().out + assert out.startswith("Password: ") + assert out.count("*") == 4 + + +def test_prompt_seed_words_valid(monkeypatch): + from mnemonic import Mnemonic + + m = Mnemonic("english") + phrase = m.generate(strength=128) + words = phrase.split() + + word_iter = iter(words) + monkeypatch.setattr(seed_prompt, "masked_input", lambda *_: next(word_iter)) + monkeypatch.setattr("builtins.input", lambda *_: "y") + + result = seed_prompt.prompt_seed_words(len(words)) + assert result == phrase + + +def test_prompt_seed_words_invalid_word(monkeypatch): + from mnemonic import Mnemonic + + m = Mnemonic("english") + phrase = m.generate(strength=128) + words = phrase.split() + # Insert an invalid word for the first entry then the correct one + inputs = iter(["invalid"] + words) + monkeypatch.setattr(seed_prompt, "masked_input", lambda *_: next(inputs)) + monkeypatch.setattr("builtins.input", lambda *_: "y") + + result = seed_prompt.prompt_seed_words(len(words)) + assert result == phrase diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 01e058c..6e83033 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -25,6 +25,7 @@ try: update_checksum_file, ) from .password_prompt import prompt_for_password + from .seed_prompt import masked_input, prompt_seed_words from .input_utils import timed_input from .memory_protection import InMemorySecret from .clipboard import copy_to_clipboard @@ -58,6 +59,8 @@ __all__ = [ "exclusive_lock", "shared_lock", "prompt_for_password", + "masked_input", + "prompt_seed_words", "timed_input", "InMemorySecret", "copy_to_clipboard", diff --git a/src/utils/fingerprint_manager.py b/src/utils/fingerprint_manager.py index 34ee4a0..470b063 100644 --- a/src/utils/fingerprint_manager.py +++ b/src/utils/fingerprint_manager.py @@ -34,7 +34,11 @@ class FingerprintManager: self.app_dir = app_dir self.fingerprints_file = self.app_dir / "fingerprints.json" self._ensure_app_directory() - self.fingerprints, self.current_fingerprint = self._load_fingerprints() + ( + self.fingerprints, + self.current_fingerprint, + self.names, + ) = self._load_fingerprints() def get_current_fingerprint_dir(self) -> Optional[Path]: """ @@ -62,25 +66,26 @@ class FingerprintManager: ) raise - def _load_fingerprints(self) -> tuple[list[str], Optional[str]]: - """Return stored fingerprints and the last used fingerprint.""" + def _load_fingerprints(self) -> tuple[list[str], Optional[str], dict[str, str]]: + """Return stored fingerprints, the last used fingerprint, and name mapping.""" try: if self.fingerprints_file.exists(): with open(self.fingerprints_file, "r") as f: data = json.load(f) fingerprints = data.get("fingerprints", []) current = data.get("last_used") + names = data.get("names", {}) logger.debug( f"Loaded fingerprints: {fingerprints} (last used: {current})" ) - return fingerprints, current + return fingerprints, current, names logger.debug( "fingerprints.json not found. Initializing empty fingerprint list." ) - return [], None + return [], None, {} except Exception as e: logger.error(f"Failed to load fingerprints: {e}", exc_info=True) - return [], None + return [], None, {} def _save_fingerprints(self): """ @@ -92,6 +97,7 @@ class FingerprintManager: { "fingerprints": self.fingerprints, "last_used": self.current_fingerprint, + "names": self.names, }, f, indent=4, @@ -116,6 +122,7 @@ class FingerprintManager: fingerprint = generate_fingerprint(seed_phrase) if fingerprint and fingerprint not in self.fingerprints: self.fingerprints.append(fingerprint) + self.names.setdefault(fingerprint, "") self.current_fingerprint = fingerprint self._save_fingerprints() logger.info(f"Fingerprint {fingerprint} added successfully.") @@ -144,6 +151,7 @@ class FingerprintManager: if fingerprint in self.fingerprints: try: self.fingerprints.remove(fingerprint) + self.names.pop(fingerprint, None) if self.current_fingerprint == fingerprint: self.current_fingerprint = ( self.fingerprints[0] if self.fingerprints else None @@ -198,6 +206,26 @@ class FingerprintManager: logger.error(f"Fingerprint {fingerprint} not found.") return False + def set_name(self, fingerprint: str, name: str | None) -> bool: + """Set a custom name for a fingerprint.""" + if fingerprint not in self.fingerprints: + return False + if name: + self.names[fingerprint] = name + else: + self.names.pop(fingerprint, None) + self._save_fingerprints() + return True + + def get_name(self, fingerprint: str) -> Optional[str]: + """Return the custom name for ``fingerprint`` if set.""" + return self.names.get(fingerprint) or None + + def display_name(self, fingerprint: str) -> str: + """Return name and fingerprint for display.""" + name = self.get_name(fingerprint) + return f"{name} ({fingerprint})" if name else fingerprint + def get_fingerprint_directory(self, fingerprint: str) -> Optional[Path]: """ Retrieves the directory path for a given fingerprint. diff --git a/src/utils/seed_prompt.py b/src/utils/seed_prompt.py new file mode 100644 index 0000000..7c83977 --- /dev/null +++ b/src/utils/seed_prompt.py @@ -0,0 +1,152 @@ +import os +import sys + +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 + + +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 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 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.""" + if sys.platform == "win32": + return _masked_input_windows(prompt) + return _masked_input_posix(prompt) + + +def prompt_seed_words(count: int = 12) -> 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``. + + 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 + + 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.") + 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.") + # Ask for confirmation again with the new word + else: + print("Please respond with 'Y' or 'N'.") + continue + + phrase = " ".join(words) + if not m.check(phrase): + raise ValueError("Invalid BIP-39 seed phrase") + return phrase diff --git a/src/utils/terminal_utils.py b/src/utils/terminal_utils.py index f1e05e7..3b1e853 100644 --- a/src/utils/terminal_utils.py +++ b/src/utils/terminal_utils.py @@ -8,6 +8,20 @@ from termcolor import colored from utils.color_scheme import color_text +def format_profile(fingerprint: str | None, pm=None) -> str | None: + """Return display string for a fingerprint with optional custom name.""" + if not fingerprint: + return None + if pm and getattr(pm, "fingerprint_manager", None): + try: + name = pm.fingerprint_manager.get_name(fingerprint) + if name: + return f"{name} ({fingerprint})" + except Exception: + pass + return fingerprint + + def clear_screen() -> None: """Clear the terminal screen using an ANSI escape code.""" print("\033c", end="") @@ -18,16 +32,17 @@ def clear_and_print_fingerprint( breadcrumb: str | None = None, parent_fingerprint: str | None = None, child_fingerprint: str | None = None, + pm=None, ) -> None: """Clear the screen and optionally display the current fingerprint and path.""" clear_screen() header_fp = None if parent_fingerprint and child_fingerprint: - header_fp = f"{parent_fingerprint} > Managed Account > {child_fingerprint}" + header_fp = f"{format_profile(parent_fingerprint, pm)} > Managed Account > {format_profile(child_fingerprint, pm)}" elif fingerprint: - header_fp = fingerprint + header_fp = format_profile(fingerprint, pm) elif parent_fingerprint or child_fingerprint: - header_fp = parent_fingerprint or child_fingerprint + header_fp = format_profile(parent_fingerprint or child_fingerprint, pm) if header_fp: header = f"Seed Profile: {header_fp}" if breadcrumb: @@ -36,15 +51,15 @@ def clear_and_print_fingerprint( def clear_and_print_profile_chain( - fingerprints: list[str] | None, breadcrumb: str | None = None + fingerprints: list[str] | None, breadcrumb: str | None = None, pm=None ) -> None: """Clear the screen and display a chain of fingerprints.""" clear_screen() if not fingerprints: return - chain = fingerprints[0] + chain = format_profile(fingerprints[0], pm) for fp in fingerprints[1:]: - chain += f" > Managed Account > {fp}" + chain += f" > Managed Account > {format_profile(fp, pm)}" header = f"Seed Profile: {chain}" if breadcrumb: header += f" > {breadcrumb}" @@ -63,11 +78,11 @@ def clear_header_with_notification( clear_screen() header_fp = None if parent_fingerprint and child_fingerprint: - header_fp = f"{parent_fingerprint} > Managed Account > {child_fingerprint}" + header_fp = f"{format_profile(parent_fingerprint, pm)} > Managed Account > {format_profile(child_fingerprint, pm)}" elif fingerprint: - header_fp = fingerprint + header_fp = format_profile(fingerprint, pm) elif parent_fingerprint or child_fingerprint: - header_fp = parent_fingerprint or child_fingerprint + header_fp = format_profile(parent_fingerprint or child_fingerprint, pm) if header_fp: header = f"Seed Profile: {header_fp}" if breadcrumb: