From ce90e7348f267f19d8d9b97c39469355a5655ca6 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 5 Jul 2025 09:24:26 -0400 Subject: [PATCH 1/2] Add list entries feature --- src/main.py | 22 ++- src/password_manager/entry_management.py | 36 ++++ src/password_manager/manager.py | 199 +++++++++++++---------- 3 files changed, 165 insertions(+), 92 deletions(-) diff --git a/src/main.py b/src/main.py index 5915c45..f17c94c 100644 --- a/src/main.py +++ b/src/main.py @@ -701,10 +701,11 @@ def display_menu( 1. Add Entry 2. Retrieve Entry 3. Search Entries - 4. Modify an Existing Entry - 5. 2FA Codes - 6. Settings - 7. Exit + 4. List Entries + 5. Modify an Existing Entry + 6. 2FA Codes + 7. Settings + 8. Exit """ display_fn = getattr(password_manager, "display_stats", None) if callable(display_fn): @@ -729,7 +730,7 @@ def display_menu( print(colored(menu, "cyan")) try: choice = timed_input( - "Enter your choice (1-7): ", inactivity_timeout + "Enter your choice (1-8): ", inactivity_timeout ).strip() except TimeoutError: print(colored("Session timed out. Vault locked.", "yellow")) @@ -740,7 +741,7 @@ def display_menu( if not choice: print( colored( - "No input detected. Please enter a number between 1 and 7.", + "No input detected. Please enter a number between 1 and 8.", "yellow", ) ) @@ -787,14 +788,17 @@ def display_menu( password_manager.handle_search_entries() elif choice == "4": password_manager.update_activity() - password_manager.handle_modify_entry() + password_manager.handle_list_entries() elif choice == "5": password_manager.update_activity() - password_manager.handle_display_totp_codes() + password_manager.handle_modify_entry() elif choice == "6": password_manager.update_activity() - handle_settings(password_manager) + password_manager.handle_display_totp_codes() elif choice == "7": + password_manager.update_activity() + handle_settings(password_manager) + elif choice == "8": logging.info("Exiting the program.") print(colored("Exiting the program.", "green")) password_manager.nostr_client.close_client_pool() diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 48a6081..0f16424 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -841,3 +841,39 @@ class EntryManager: logger.error(f"Failed to list all entries: {e}", exc_info=True) print(colored(f"Error: Failed to list all entries: {e}", "red")) return + + def get_entry_summaries( + self, filter_kind: str | None = None + ) -> list[tuple[int, str]]: + """Return a list of entry index and display labels.""" + try: + data = self.vault.load_index() + entries_data = data.get("entries", {}) + + summaries: list[tuple[int, str]] = [] + for idx_str, entry in entries_data.items(): + etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) + if filter_kind and etype != filter_kind: + continue + if etype == EntryType.PASSWORD.value: + label = entry.get("website", "") + elif etype == EntryType.TOTP.value: + label = entry.get("label", "") + elif etype == EntryType.SSH.value: + label = "SSH Key" + elif etype == EntryType.SEED.value: + label = "Seed Phrase" + elif etype == EntryType.NOSTR.value: + label = entry.get("label", "Nostr Key") + elif etype == EntryType.PGP.value: + label = "PGP Key" + else: + label = etype + summaries.append((int(idx_str), label)) + + summaries.sort(key=lambda x: x[0]) + return summaries + except Exception as e: + logger.error(f"Failed to get entry summaries: {e}", exc_info=True) + print(colored(f"Error: Failed to get entry summaries: {e}", "red")) + return [] diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 6e16c7f..4b20908 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1752,93 +1752,126 @@ class PasswordManager: print(colored("\n[+] Search Results:\n", "green")) for match in results: - index, website, username, url, blacklisted = match - entry = self.entry_manager.retrieve_entry(index) - if not entry: - continue - etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) - print(colored(f"Index: {index}", "cyan")) - if etype == EntryType.TOTP.value: - print(colored(f" Label: {entry.get('label', website)}", "cyan")) - print( - colored( - f" Derivation Index: {entry.get('index', index)}", "cyan" - ) - ) - print( - colored( - f" Period: {entry.get('period', 30)}s Digits: {entry.get('digits', 6)}", - "cyan", - ) - ) - notes = entry.get("notes", "") - if notes: - print(colored(f" Notes: {notes}", "cyan")) - elif etype == EntryType.SEED.value: - print(colored(" Type: Seed Phrase", "cyan")) - print(colored(f" Words: {entry.get('words', 24)}", "cyan")) - print( - colored( - f" Derivation Index: {entry.get('index', index)}", "cyan" - ) - ) - notes = entry.get("notes", "") - if notes: - print(colored(f" Notes: {notes}", "cyan")) - elif etype == EntryType.SSH.value: - print(colored(" Type: SSH Key", "cyan")) - print( - colored( - f" Derivation Index: {entry.get('index', index)}", "cyan" - ) - ) - notes = entry.get("notes", "") - if notes: - print(colored(f" Notes: {notes}", "cyan")) - elif etype == EntryType.PGP.value: - print(colored(" Type: PGP Key", "cyan")) - print( - colored( - f" Key Type: {entry.get('key_type', 'ed25519')}", "cyan" - ) - ) - uid = entry.get("user_id", "") - if uid: - print(colored(f" User ID: {uid}", "cyan")) - print( - colored( - f" Derivation Index: {entry.get('index', index)}", "cyan" - ) - ) - notes = entry.get("notes", "") - if notes: - print(colored(f" Notes: {notes}", "cyan")) - elif etype == EntryType.NOSTR.value: - print(colored(" Type: Nostr Key", "cyan")) - print(colored(f" Label: {entry.get('label', '')}", "cyan")) - print( - colored( - f" Derivation Index: {entry.get('index', index)}", "cyan" - ) - ) - notes = entry.get("notes", "") - if notes: - print(colored(f" Notes: {notes}", "cyan")) - else: - print(colored(f" Website: {website}", "cyan")) - print(colored(f" Username: {username or 'N/A'}", "cyan")) - print(colored(f" URL: {url or 'N/A'}", "cyan")) - print( - colored( - f" Blacklisted: {'Yes' if blacklisted else 'No'}", "cyan" - ) - ) - print("-" * 40) - + self.display_entry_details(match[0]) except Exception as e: logging.error(f"Failed to search entries: {e}", exc_info=True) print(colored(f"Error: Failed to search entries: {e}", "red")) + def display_entry_details(self, index: int) -> None: + """Print detailed information for a single entry.""" + entry = self.entry_manager.retrieve_entry(index) + if not entry: + return + + etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) + print(colored(f"Index: {index}", "cyan")) + if etype == EntryType.TOTP.value: + print(colored(f" Label: {entry.get('label', '')}", "cyan")) + print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan")) + print( + colored( + f" Period: {entry.get('period', 30)}s Digits: {entry.get('digits', 6)}", + "cyan", + ) + ) + notes = entry.get("notes", "") + if notes: + print(colored(f" Notes: {notes}", "cyan")) + elif etype == EntryType.SEED.value: + print(colored(" Type: Seed Phrase", "cyan")) + print(colored(f" Words: {entry.get('words', 24)}", "cyan")) + print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan")) + notes = entry.get("notes", "") + if notes: + print(colored(f" Notes: {notes}", "cyan")) + elif etype == EntryType.SSH.value: + print(colored(" Type: SSH Key", "cyan")) + print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan")) + notes = entry.get("notes", "") + if notes: + print(colored(f" Notes: {notes}", "cyan")) + elif etype == EntryType.PGP.value: + print(colored(" Type: PGP Key", "cyan")) + print(colored(f" Key Type: {entry.get('key_type', 'ed25519')}", "cyan")) + uid = entry.get("user_id", "") + if uid: + print(colored(f" User ID: {uid}", "cyan")) + print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan")) + notes = entry.get("notes", "") + if notes: + print(colored(f" Notes: {notes}", "cyan")) + elif etype == EntryType.NOSTR.value: + print(colored(" Type: Nostr Key", "cyan")) + print(colored(f" Label: {entry.get('label', '')}", "cyan")) + print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan")) + notes = entry.get("notes", "") + if notes: + print(colored(f" Notes: {notes}", "cyan")) + else: + website = entry.get("website", "") + username = entry.get("username", "") + url = entry.get("url", "") + blacklisted = entry.get("blacklisted", False) + print(colored(f" Website: {website}", "cyan")) + print(colored(f" Username: {username or 'N/A'}", "cyan")) + print(colored(f" URL: {url or 'N/A'}", "cyan")) + print(colored(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "cyan")) + print("-" * 40) + + def handle_list_entries(self) -> None: + """List entries and optionally show details.""" + try: + while True: + print("\nList Entries:") + print("1. All") + print("2. Passwords") + print("3. 2FA (TOTP)") + print("4. SSH Key") + print("5. Seed Phrase") + print("6. Nostr Key Pair") + print("7. PGP") + print("8. Back") + choice = input("Select entry type: ").strip() + if choice == "1": + filter_kind = None + elif choice == "2": + filter_kind = EntryType.PASSWORD.value + elif choice == "3": + filter_kind = EntryType.TOTP.value + elif choice == "4": + filter_kind = EntryType.SSH.value + elif choice == "5": + filter_kind = EntryType.SEED.value + elif choice == "6": + filter_kind = EntryType.NOSTR.value + elif choice == "7": + filter_kind = EntryType.PGP.value + elif choice == "8": + return + else: + print(colored("Invalid choice.", "red")) + continue + + summaries = self.entry_manager.get_entry_summaries(filter_kind) + if not summaries: + continue + print(colored("\n[+] Entries:\n", "green")) + for idx, label in summaries: + print(colored(f"{idx}. {label}", "cyan")) + idx_input = input( + "Enter index to view details or press Enter to return: " + ).strip() + if not idx_input: + return + if not idx_input.isdigit(): + print(colored("Invalid index.", "red")) + continue + self.display_entry_details(int(idx_input)) + return + except Exception as e: + logging.error(f"Failed to list entries: {e}", exc_info=True) + print(colored(f"Error: Failed to list entries: {e}", "red")) + def delete_entry(self) -> None: """Deletes an entry from the password index.""" try: From ed00cbc34b407ca62b3a90dcd0803aea697e87f9 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 5 Jul 2025 09:34:45 -0400 Subject: [PATCH 2/2] Fix menu numbering and add list entries test --- src/tests/test_auto_sync.py | 2 +- src/tests/test_cli_invalid_input.py | 8 ++--- src/tests/test_inactivity_lock.py | 4 +-- src/tests/test_manager_list_entries.py | 43 ++++++++++++++++++++++++++ src/tests/test_menu_options.py | 4 +-- src/tests/test_menu_search.py | 2 +- 6 files changed, 53 insertions(+), 10 deletions(-) create mode 100644 src/tests/test_manager_list_entries.py diff --git a/src/tests/test_auto_sync.py b/src/tests/test_auto_sync.py index bdd9f28..7c8466e 100644 --- a/src/tests/test_auto_sync.py +++ b/src/tests/test_auto_sync.py @@ -31,7 +31,7 @@ def test_auto_sync_triggers_post(monkeypatch): called = True monkeypatch.setattr(main, "handle_post_to_nostr", fake_post) - monkeypatch.setattr(main, "timed_input", lambda *_: "7") + monkeypatch.setattr(main, "timed_input", lambda *_: "8") with pytest.raises(SystemExit): main.display_menu(pm, sync_interval=0.1) diff --git a/src/tests/test_cli_invalid_input.py b/src/tests/test_cli_invalid_input.py index f16a147..7fa4b16 100644 --- a/src/tests/test_cli_invalid_input.py +++ b/src/tests/test_cli_invalid_input.py @@ -52,7 +52,7 @@ def _make_pm(called, locked=None): def test_empty_and_non_numeric_choice(monkeypatch, capsys): called = {"add": False, "retrieve": False, "modify": False} pm, _ = _make_pm(called) - inputs = iter(["", "abc", "7"]) + inputs = iter(["", "abc", "8"]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) with pytest.raises(SystemExit): main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) @@ -65,7 +65,7 @@ def test_empty_and_non_numeric_choice(monkeypatch, capsys): def test_out_of_range_menu(monkeypatch, capsys): called = {"add": False, "retrieve": False, "modify": False} pm, _ = _make_pm(called) - inputs = iter(["9", "7"]) + inputs = iter(["9", "8"]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) with pytest.raises(SystemExit): main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) @@ -77,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", "7", "7"]) + inputs = iter(["1", "8", "7", "8"]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) with pytest.raises(SystemExit): @@ -92,7 +92,7 @@ def test_inactivity_timeout_loop(monkeypatch, capsys): pm, locked = _make_pm(called) pm.last_activity = 0 monkeypatch.setattr(time, "time", lambda: 100.0) - monkeypatch.setattr(main, "timed_input", lambda *_: "7") + monkeypatch.setattr(main, "timed_input", lambda *_: "8") with pytest.raises(SystemExit): main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1) out = capsys.readouterr().out diff --git a/src/tests/test_inactivity_lock.py b/src/tests/test_inactivity_lock.py index c8a4ed7..46253f4 100644 --- a/src/tests/test_inactivity_lock.py +++ b/src/tests/test_inactivity_lock.py @@ -36,7 +36,7 @@ def test_inactivity_triggers_lock(monkeypatch): unlock_vault=unlock_vault, ) - monkeypatch.setattr(main, "timed_input", lambda *_: "7") + monkeypatch.setattr(main, "timed_input", lambda *_: "8") with pytest.raises(SystemExit): main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1) @@ -72,7 +72,7 @@ def test_input_timeout_triggers_lock(monkeypatch): unlock_vault=unlock_vault, ) - responses = iter([TimeoutError(), "7"]) + responses = iter([TimeoutError(), "8"]) def fake_input(*_args, **_kwargs): val = next(responses) diff --git a/src/tests/test_manager_list_entries.py b/src/tests/test_manager_list_entries.py new file mode 100644 index 0000000..97279d6 --- /dev/null +++ b/src/tests/test_manager_list_entries.py @@ -0,0 +1,43 @@ +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace + +from helpers import create_vault, TEST_SEED, TEST_PASSWORD + +import sys + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager +from password_manager.manager import PasswordManager, EncryptionMode +from password_manager.config_manager import ConfigManager + + +def test_handle_list_entries(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.nostr_client = SimpleNamespace() + pm.fingerprint_dir = tmp_path + + entry_mgr.add_totp("Example", TEST_SEED) + entry_mgr.add_entry("example.com", 12) + + inputs = iter(["1", ""]) # list all, then exit + monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) + + pm.handle_list_entries() + out = capsys.readouterr().out + assert "Example" in out + assert "example.com" in out diff --git a/src/tests/test_menu_options.py b/src/tests/test_menu_options.py index 1412047..c45ba9d 100644 --- a/src/tests/test_menu_options.py +++ b/src/tests/test_menu_options.py @@ -30,7 +30,7 @@ def _make_pm(calls): def test_menu_totp_option(monkeypatch): calls = [] pm = _make_pm(calls) - inputs = iter(["5", "7"]) + inputs = iter(["6", "8"]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr(main, "handle_settings", lambda *_: None) with pytest.raises(SystemExit): @@ -41,7 +41,7 @@ def test_menu_totp_option(monkeypatch): def test_menu_settings_option(monkeypatch): calls = [] pm = _make_pm(calls) - inputs = iter(["6", "7"]) + inputs = iter(["7", "8"]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr(main, "handle_settings", lambda *_: calls.append("settings")) with pytest.raises(SystemExit): diff --git a/src/tests/test_menu_search.py b/src/tests/test_menu_search.py index 65dc386..7f1d70e 100644 --- a/src/tests/test_menu_search.py +++ b/src/tests/test_menu_search.py @@ -30,7 +30,7 @@ def _make_pm(called): def test_menu_search_option(monkeypatch): called = [] pm = _make_pm(called) - inputs = iter(["3", "7"]) + inputs = iter(["3", "8"]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr("builtins.input", lambda *_: "query") with pytest.raises(SystemExit):