From 368e44c56fc27ebd70b4e1d4c531940f733c3cf8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 21:27:30 -0400 Subject: [PATCH] Add managed account support --- src/main.py | 25 +++- src/password_manager/manager.py | 172 +++++++++++++++++++++++----- src/tests/test_cli_invalid_input.py | 3 +- src/utils/terminal_utils.py | 16 +++ 4 files changed, 183 insertions(+), 33 deletions(-) diff --git a/src/main.py b/src/main.py index d9afad2..41d50f1 100644 --- a/src/main.py +++ b/src/main.py @@ -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": diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index bd79bb7..32ff7da 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -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")) diff --git a/src/tests/test_cli_invalid_input.py b/src/tests/test_cli_invalid_input.py index cfdf2bb..589f162 100644 --- a/src/tests/test_cli_invalid_input.py +++ b/src/tests/test_cli_invalid_input.py @@ -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): diff --git a/src/utils/terminal_utils.py b/src/utils/terminal_utils.py index 00f1975..ca806dd 100644 --- a/src/utils/terminal_utils.py +++ b/src/utils/terminal_utils.py @@ -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():