Add managed account support

This commit is contained in:
thePR0M3TH3AN
2025-07-07 21:27:30 -04:00
parent d9176dd035
commit 368e44c56f
4 changed files with 183 additions and 33 deletions

View File

@@ -588,7 +588,8 @@ def handle_profiles_menu(password_manager: PasswordManager) -> None:
"""Submenu for managing seed profiles."""
while True:
clear_and_print_fingerprint(
getattr(password_manager, "current_fingerprint", None),
getattr(password_manager, "header_fingerprint", None)
or getattr(password_manager, "current_fingerprint", None),
"Main Menu > Settings > Profiles",
)
print(color_text("\nProfiles:", "menu"))
@@ -627,7 +628,8 @@ def handle_nostr_menu(password_manager: PasswordManager) -> None:
while True:
clear_and_print_fingerprint(
getattr(password_manager, "current_fingerprint", None),
getattr(password_manager, "header_fingerprint", None)
or getattr(password_manager, "current_fingerprint", None),
"Main Menu > Settings > Nostr",
)
print(color_text("\nNostr Settings:", "menu"))
@@ -664,7 +666,8 @@ def handle_settings(password_manager: PasswordManager) -> None:
"""Interactive settings menu with submenus for profiles and Nostr."""
while True:
clear_and_print_fingerprint(
getattr(password_manager, "current_fingerprint", None),
getattr(password_manager, "header_fingerprint", None)
or getattr(password_manager, "current_fingerprint", None),
"Main Menu > Settings",
)
print(color_text("\nSettings:", "menu"))
@@ -758,7 +761,8 @@ def display_menu(
pause()
while True:
clear_and_print_fingerprint(
getattr(password_manager, "current_fingerprint", None),
getattr(password_manager, "header_fingerprint", None)
or getattr(password_manager, "current_fingerprint", None),
"Main Menu",
)
if time.time() - password_manager.last_activity > inactivity_timeout:
@@ -790,6 +794,9 @@ def display_menu(
continue
password_manager.update_activity()
if not choice:
if getattr(password_manager, "profile_stack", []):
password_manager.exit_managed_account()
continue
logging.info("Exiting the program.")
print(colored("Exiting the program.", "green"))
password_manager.nostr_client.close_client_pool()
@@ -797,7 +804,8 @@ def display_menu(
if choice == "1":
while True:
clear_and_print_fingerprint(
getattr(password_manager, "current_fingerprint", None),
getattr(password_manager, "header_fingerprint", None)
or getattr(password_manager, "current_fingerprint", None),
"Main Menu > Add Entry",
)
print(color_text("\nAdd Entry:", "menu"))
@@ -808,6 +816,7 @@ def display_menu(
print(color_text("5. Nostr Key Pair", "menu"))
print(color_text("6. PGP Key", "menu"))
print(color_text("7. Key/Value", "menu"))
print(color_text("8. Managed Account", "menu"))
sub_choice = input(
"Select entry type or press Enter to go back: "
).strip()
@@ -833,6 +842,9 @@ def display_menu(
elif sub_choice == "7":
password_manager.handle_add_key_value()
break
elif sub_choice == "8":
password_manager.handle_add_managed_account()
break
elif not sub_choice:
break
else:
@@ -841,7 +853,8 @@ def display_menu(
password_manager.update_activity()
password_manager.handle_retrieve_entry()
clear_and_print_fingerprint(
getattr(password_manager, "current_fingerprint", None),
getattr(password_manager, "header_fingerprint", None)
or getattr(password_manager, "current_fingerprint", None),
"Main Menu",
)
elif choice == "3":

View File

@@ -56,6 +56,7 @@ from utils.terminal_utils import (
clear_screen,
pause,
clear_and_print_fingerprint,
clear_and_print_profile_chain,
)
from utils.fingerprint import generate_fingerprint
from constants import MIN_HEALTHY_RELAYS
@@ -169,6 +170,21 @@ class PasswordManager:
else:
self._parent_seed_secret = InMemorySecret(value.encode("utf-8"))
@property
def header_fingerprint(self) -> str | None:
"""Return the fingerprint chain for header display."""
if not getattr(self, "current_fingerprint", None):
return None
if not self.profile_stack:
return self.current_fingerprint
chain = [fp for fp, _path, _seed in self.profile_stack] + [
self.current_fingerprint
]
header = chain[0]
for fp in chain[1:]:
header += f" > Managed Account > {fp}"
return header
def update_activity(self) -> None:
"""Record the current time as the last user activity."""
self.last_activity = time.time()
@@ -941,7 +957,7 @@ class PasswordManager:
def handle_add_password(self) -> None:
try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > Add Entry > Password",
)
website_name = input("Enter the label or website name: ").strip()
@@ -1031,7 +1047,7 @@ class PasswordManager:
"""Add a TOTP entry either derived from the seed or imported."""
try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > Add Entry > 2FA (TOTP)",
)
while True:
@@ -1138,7 +1154,7 @@ class PasswordManager:
"""Add an SSH key pair entry and display the derived keys."""
try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > Add Entry > SSH Key",
)
label = input("Label: ").strip()
@@ -1183,7 +1199,7 @@ class PasswordManager:
"""Add a derived BIP-39 seed phrase entry."""
try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > Add Entry > Seed Phrase",
)
label = input("Label: ").strip()
@@ -1242,7 +1258,7 @@ class PasswordManager:
"""Add a PGP key entry and display the generated key."""
try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > Add Entry > PGP Key",
)
label = input("Label: ").strip()
@@ -1298,7 +1314,7 @@ class PasswordManager:
"""Add a Nostr key entry and display the derived keys."""
try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > Add Entry > Nostr Key Pair",
)
label = input("Label: ").strip()
@@ -1345,7 +1361,7 @@ class PasswordManager:
"""Add a generic key/value entry."""
try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > Add Entry > Key/Value",
)
label = input("Label: ").strip()
@@ -1403,6 +1419,63 @@ class PasswordManager:
print(colored(f"Error: Failed to add key/value entry: {e}", "red"))
pause()
def handle_add_managed_account(self) -> None:
"""Add a managed account seed entry."""
try:
clear_and_print_fingerprint(
getattr(self, "header_fingerprint", None),
"Main Menu > Add Entry > Managed Account",
)
label = input("Label: ").strip()
if not label:
print(colored("Error: Label cannot be empty.", "red"))
return
words_input = input("Word count (12 or 24, default 24): ").strip()
notes = input("Notes (optional): ").strip()
if words_input and words_input not in {"12", "24"}:
print(colored("Invalid word count. Choose 12 or 24.", "red"))
return
words = int(words_input) if words_input else 24
index = self.entry_manager.add_managed_account(
label, self.parent_seed, word_count=words, notes=notes
)
seed = self.entry_manager.get_managed_account_seed(index, self.parent_seed)
self.is_dirty = True
self.last_update = time.time()
print(
colored(
f"\n[+] Managed account '{label}' added with ID {index}.\n",
"green",
)
)
if confirm_action("Reveal seed now? (y/N): "):
if self.secret_mode_enabled:
copy_to_clipboard(seed, self.clipboard_clear_delay)
print(
colored(
f"[+] Seed copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.",
"green",
)
)
else:
print(color_text(seed, "deterministic"))
if confirm_action("Show Compact Seed QR? (Y/N): "):
from password_manager.seedqr import encode_seedqr
TotpManager.print_qr_code(encode_seedqr(seed))
try:
self.sync_vault()
except Exception as nostr_error: # pragma: no cover - best effort
logging.error(
f"Failed to post updated index to Nostr: {nostr_error}",
exc_info=True,
)
pause()
except Exception as e:
logging.error(f"Error during managed account setup: {e}", exc_info=True)
print(colored(f"Error: Failed to add managed account: {e}", "red"))
pause()
def show_entry_details_by_index(self, index: int) -> None:
"""Display entry details using :meth:`handle_retrieve_entry` for the
given index without prompting for it again."""
@@ -1447,7 +1520,7 @@ class PasswordManager:
"""
try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > Retrieve Entry",
)
index_input = input(
@@ -1679,10 +1752,7 @@ class PasswordManager:
pause()
return
if entry_type in (
EntryType.KEY_VALUE.value,
EntryType.MANAGED_ACCOUNT.value,
):
if entry_type == EntryType.KEY_VALUE.value:
label = entry.get("label", "")
value = entry.get("value", "")
notes = entry.get("notes", "")
@@ -1738,6 +1808,56 @@ class PasswordManager:
self._prompt_toggle_archive(entry, index)
pause()
return
if entry_type == EntryType.MANAGED_ACCOUNT.value:
label = entry.get("label", "")
notes = entry.get("notes", "")
archived = entry.get("archived", False)
fingerprint = entry.get("fingerprint", "")
print(colored(f"Managed account '{label}'.", "cyan"))
if notes:
print(colored(f"Notes: {notes}", "cyan"))
if fingerprint:
print(colored(f"Fingerprint: {fingerprint}", "cyan"))
print(
colored(
f"Archived Status: {'Archived' if archived else 'Active'}",
"cyan",
)
)
action = (
input(
"Enter 'r' to reveal seed, 'l' to load account, or press Enter to go back: "
)
.strip()
.lower()
)
if action == "r":
seed = self.entry_manager.get_managed_account_seed(
index, self.parent_seed
)
if self.secret_mode_enabled:
copy_to_clipboard(seed, self.clipboard_clear_delay)
print(
colored(
f"[+] Seed phrase copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.",
"green",
)
)
else:
print(color_text(seed, "deterministic"))
if confirm_action("Show Compact Seed QR? (Y/N): "):
from password_manager.seedqr import encode_seedqr
TotpManager.print_qr_code(encode_seedqr(seed))
self._prompt_toggle_archive(entry, index)
pause()
return
if action == "l":
self.load_managed_account(index)
return
self._prompt_toggle_archive(entry, index)
pause()
return
website_name = entry.get("website")
length = entry.get("length")
@@ -1837,7 +1957,7 @@ class PasswordManager:
"""
try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > Modify Entry",
)
index_input = input(
@@ -2165,7 +2285,7 @@ class PasswordManager:
"""Prompt for a query, list matches and optionally show details."""
try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > Search Entries",
)
query = input("Enter search string: ").strip()
@@ -2182,7 +2302,7 @@ class PasswordManager:
while True:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > Search Entries",
)
print(colored("\n[+] Search Results:\n", "green"))
@@ -2295,7 +2415,7 @@ class PasswordManager:
try:
while True:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > List Entries",
)
print(color_text("\nList Entries:", "menu"))
@@ -2337,7 +2457,7 @@ class PasswordManager:
continue
while True:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > List Entries",
)
print(colored("\n[+] Entries:\n", "green"))
@@ -2426,7 +2546,7 @@ class PasswordManager:
return
while True:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > Archived Entries",
)
print(colored("\n[+] Archived Entries:\n", "green"))
@@ -2480,7 +2600,7 @@ class PasswordManager:
"""Display all stored TOTP codes with a countdown progress bar."""
try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > 2FA Codes",
)
data = self.entry_manager.vault.load_index()
@@ -2503,7 +2623,7 @@ class PasswordManager:
print(colored("Press Enter to return to the menu.", "cyan"))
while True:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > 2FA Codes",
)
print(colored("Press Enter to return to the menu.", "cyan"))
@@ -2561,7 +2681,7 @@ class PasswordManager:
"""
try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > Settings > Verify Script Checksum",
)
current_checksum = calculate_checksum(__file__)
@@ -2599,7 +2719,7 @@ class PasswordManager:
return
try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > Settings > Generate Script Checksum",
)
script_path = Path(__file__).resolve()
@@ -2712,7 +2832,7 @@ class PasswordManager:
"""Export the current database to an encrypted portable file."""
try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > Settings > Export database",
)
path = export_backup(
@@ -2732,7 +2852,7 @@ class PasswordManager:
"""Import a portable database file, replacing the current index."""
try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > Settings > Import database",
)
import_backup(
@@ -2750,7 +2870,7 @@ class PasswordManager:
"""Export all 2FA codes to a JSON file for other authenticator apps."""
try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > Settings > Export 2FA codes",
)
data = self.entry_manager.vault.load_index()
@@ -2813,7 +2933,7 @@ class PasswordManager:
"""
try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
getattr(self, "header_fingerprint", None),
"Main Menu > Settings > Backup Parent Seed",
)
print(colored("\n=== Backup Parent Seed ===", "yellow"))

View File

@@ -40,6 +40,7 @@ def _make_pm(called, locked=None):
nostr_client=SimpleNamespace(close_client_pool=lambda: None),
handle_add_password=add,
handle_add_totp=lambda: None,
handle_add_managed_account=lambda: None,
handle_retrieve_entry=retrieve,
handle_modify_entry=modify,
update_activity=update,
@@ -76,7 +77,7 @@ def test_out_of_range_menu(monkeypatch, capsys):
def test_invalid_add_entry_submenu(monkeypatch, capsys):
called = {"add": False, "retrieve": False, "modify": False}
pm, _ = _make_pm(called)
inputs = iter(["1", "8", "", ""])
inputs = iter(["1", "9", "", ""])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
with pytest.raises(SystemExit):

View File

@@ -23,6 +23,22 @@ def clear_and_print_fingerprint(
print(colored(header, "green"))
def clear_and_print_profile_chain(
fingerprints: list[str] | None, breadcrumb: str | None = None
) -> None:
"""Clear the screen and display a chain of fingerprints."""
clear_screen()
if not fingerprints:
return
chain = fingerprints[0]
for fp in fingerprints[1:]:
chain += f" > Managed Account > {fp}"
header = f"Seed Profile: {chain}"
if breadcrumb:
header += f" > {breadcrumb}"
print(colored(header, "green"))
def pause(message: str = "Press Enter to continue...") -> None:
"""Wait for the user to press Enter before proceeding."""
if not sys.stdin or not sys.stdin.isatty():