Merge pull request #586 from PR0M3TH3AN/beta

Beta
This commit is contained in:
thePR0M3TH3AN
2025-07-16 14:32:09 -04:00
committed by GitHub
11 changed files with 511 additions and 117 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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:

View File

@@ -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

View File

@@ -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")

View File

@@ -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()

View File

@@ -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

View File

@@ -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",

View File

@@ -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.

152
src/utils/seed_prompt.py Normal file
View File

@@ -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

View File

@@ -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: