mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 07:18:47 +00:00
11
README.md
11
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.
|
||||
|
||||
|
@@ -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.
|
||||
|
||||
|
31
src/main.py
31
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:
|
||||
|
@@ -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
|
||||
|
65
src/tests/test_manager_seed_setup.py
Normal file
65
src/tests/test_manager_seed_setup.py
Normal 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")
|
@@ -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()
|
||||
|
||||
|
60
src/tests/test_seed_prompt.py
Normal file
60
src/tests/test_seed_prompt.py
Normal 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
|
@@ -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",
|
||||
|
@@ -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
152
src/utils/seed_prompt.py
Normal 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
|
@@ -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:
|
||||
|
Reference in New Issue
Block a user