From 41cf6830a89c1719e26c752667b2eea1bdbe2bbb Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 17:41:29 -0400 Subject: [PATCH 01/16] Update README.md --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index f503b9d..925223c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -````markdown # SeedPass ![SeedPass Logo](https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/refs/heads/main/logo/png/SeedPass-Logo-03.png) @@ -536,4 +535,3 @@ For any questions, suggestions, or support, please open an issue on the [GitHub --- *Stay secure and keep your passwords safe with SeedPass!* -```` From db85cacedae4a83bc5921ca3dd6c52363c8a051b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 18:15:37 -0400 Subject: [PATCH 02/16] fix retrieval screen clearing and add pause tests --- src/password_manager/manager.py | 16 +++-- .../test_retrieve_pause_sensitive_entries.py | 58 +++++++++++++++++++ 2 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 src/tests/test_retrieve_pause_sensitive_entries.py diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 874c598..a574161 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1840,7 +1840,7 @@ class PasswordManager: child_fingerprint=child_fp, ) archived = entry.get("archived", entry.get("blacklisted", False)) - entry_type = entry.get("type", EntryType.PASSWORD.value) + entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) print(colored("\n[+] Entry Actions:", "green")) if archived: print(colored("U. Unarchive", "cyan")) @@ -1922,7 +1922,7 @@ class PasswordManager: def _entry_edit_menu(self, index: int, entry: dict) -> None: """Sub-menu for editing common entry fields.""" - entry_type = entry.get("type", EntryType.PASSWORD.value) + entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) while True: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( @@ -1982,7 +1982,7 @@ class PasswordManager: def _entry_qr_menu(self, index: int, entry: dict) -> None: """Display QR codes for the given ``entry``.""" - entry_type = entry.get("type") + entry_type = entry.get("type", entry.get("kind")) try: if entry_type in {EntryType.SEED.value, EntryType.MANAGED_ACCOUNT.value}: @@ -2068,7 +2068,7 @@ class PasswordManager: pause() return - entry_type = entry.get("type", EntryType.PASSWORD.value) + entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) if entry_type == EntryType.TOTP.value: label = entry.get("label", "") @@ -2166,6 +2166,7 @@ class PasswordManager: except Exception as e: logging.error(f"Error deriving SSH key pair: {e}", exc_info=True) print(colored(f"Error: Failed to derive SSH keys: {e}", "red")) + pause() self._entry_actions_menu(index, entry) pause() return @@ -2217,6 +2218,7 @@ class PasswordManager: except Exception as e: logging.error(f"Error deriving seed phrase: {e}", exc_info=True) print(colored(f"Error: Failed to derive seed phrase: {e}", "red")) + pause() self._entry_actions_menu(index, entry) pause() return @@ -2254,6 +2256,7 @@ class PasswordManager: except Exception as e: logging.error(f"Error deriving PGP key: {e}", exc_info=True) print(colored(f"Error: Failed to derive PGP key: {e}", "red")) + pause() self._entry_actions_menu(index, entry) pause() return @@ -2286,6 +2289,7 @@ class PasswordManager: except Exception as e: logging.error(f"Error deriving Nostr keys: {e}", exc_info=True) print(colored(f"Error: Failed to derive Nostr keys: {e}", "red")) + pause() self._entry_actions_menu(index, entry) pause() return @@ -2519,7 +2523,7 @@ class PasswordManager: if not entry: return - entry_type = entry.get("type", EntryType.PASSWORD.value) + entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) if entry_type == EntryType.TOTP.value: label = entry.get("label", "") @@ -3810,7 +3814,7 @@ class PasswordManager: entries = data.get("entries", {}) counts: dict[str, int] = {etype.value: 0 for etype in EntryType} for entry in entries.values(): - etype = entry.get("type", EntryType.PASSWORD.value) + etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) counts[etype] = counts.get(etype, 0) + 1 stats["entries"] = counts stats["total_entries"] = len(entries) diff --git a/src/tests/test_retrieve_pause_sensitive_entries.py b/src/tests/test_retrieve_pause_sensitive_entries.py new file mode 100644 index 0000000..4d48cd5 --- /dev/null +++ b/src/tests/test_retrieve_pause_sensitive_entries.py @@ -0,0 +1,58 @@ +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import create_vault, TEST_SEED, TEST_PASSWORD + +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 + +import pytest + + +@pytest.mark.parametrize( + "adder,needs_confirm", + [ + (lambda mgr: mgr.add_seed("seed", TEST_SEED), True), + (lambda mgr: mgr.add_pgp_key("pgp", TEST_SEED, user_id="test"), True), + (lambda mgr: mgr.add_ssh_key("ssh", TEST_SEED), True), + (lambda mgr: mgr.add_nostr_key("nostr"), False), + ], +) +def test_pause_before_entry_actions(monkeypatch, adder, needs_confirm): + 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.parent_seed = TEST_SEED + pm.fingerprint_dir = tmp_path + pm.secret_mode_enabled = False + + index = adder(entry_mgr) + + pause_calls = [] + monkeypatch.setattr( + "password_manager.manager.pause", lambda *a, **k: pause_calls.append(True) + ) + monkeypatch.setattr(pm, "_entry_actions_menu", lambda *a, **k: None) + monkeypatch.setattr("builtins.input", lambda *a, **k: str(index)) + if needs_confirm: + monkeypatch.setattr( + "password_manager.manager.confirm_action", lambda *a, **k: True + ) + + pm.handle_retrieve_entry() + assert len(pause_calls) == 2 From 513f6df459ac1aba2dcf7196a95c052b58aa5705 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 20:26:17 -0400 Subject: [PATCH 03/16] Fix QR menu option case handling --- src/password_manager/entry_management.py | 4 +-- src/password_manager/manager.py | 14 ++++++++ src/tests/test_nostr_qr.py | 43 ++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 0aaed6a..f2e871c 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -437,8 +437,8 @@ class EntryManager: """Return the npub and nsec for the specified entry.""" entry = self.retrieve_entry(index) - etype = entry.get("type") if entry else None - kind = entry.get("kind") if entry else None + etype = entry.get("type", "").lower() if entry else "" + kind = entry.get("kind", "").lower() if entry else "" if not entry or ( etype != EntryType.NOSTR.value and kind != EntryType.NOSTR.value ): diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index a574161..f0216d3 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1841,6 +1841,8 @@ class PasswordManager: ) archived = entry.get("archived", entry.get("blacklisted", False)) entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) + if isinstance(entry_type, str): + entry_type = entry_type.lower() print(colored("\n[+] Entry Actions:", "green")) if archived: print(colored("U. Unarchive", "cyan")) @@ -1923,6 +1925,8 @@ class PasswordManager: def _entry_edit_menu(self, index: int, entry: dict) -> None: """Sub-menu for editing common entry fields.""" entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) + if isinstance(entry_type, str): + entry_type = entry_type.lower() while True: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( @@ -1983,6 +1987,8 @@ class PasswordManager: """Display QR codes for the given ``entry``.""" entry_type = entry.get("type", entry.get("kind")) + if isinstance(entry_type, str): + entry_type = entry_type.lower() try: if entry_type in {EntryType.SEED.value, EntryType.MANAGED_ACCOUNT.value}: @@ -2069,6 +2075,8 @@ class PasswordManager: return entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) + if isinstance(entry_type, str): + entry_type = entry_type.lower() if entry_type == EntryType.TOTP.value: label = entry.get("label", "") @@ -2524,6 +2532,8 @@ class PasswordManager: return entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) + if isinstance(entry_type, str): + entry_type = entry_type.lower() if entry_type == EntryType.TOTP.value: label = entry.get("label", "") @@ -2917,6 +2927,8 @@ class PasswordManager: return etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) + if isinstance(etype, str): + etype = etype.lower() print(color_text(f"Index: {index}", "index")) if etype == EntryType.TOTP.value: print(color_text(f" Label: {entry.get('label', '')}", "index")) @@ -3815,6 +3827,8 @@ class PasswordManager: counts: dict[str, int] = {etype.value: 0 for etype in EntryType} for entry in entries.values(): etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) + if isinstance(etype, str): + etype = etype.lower() counts[etype] = counts.get(etype, 0) + 1 stats["entries"] = counts stats["total_entries"] = len(entries) diff --git a/src/tests/test_nostr_qr.py b/src/tests/test_nostr_qr.py index e2af078..0ad7fe3 100644 --- a/src/tests/test_nostr_qr.py +++ b/src/tests/test_nostr_qr.py @@ -93,3 +93,46 @@ def test_show_private_key_qr(monkeypatch, capsys): out = capsys.readouterr().out assert called == [nsec] assert color_text(f"nsec: {nsec}", "deterministic") in out + + +def test_qr_menu_case_insensitive(monkeypatch): + """QR menu should appear even if entry type is uppercase.""" + 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.parent_seed = TEST_SEED + pm.nostr_client = FakeNostrClient() + pm.fingerprint_dir = tmp_path + pm.is_dirty = False + pm.secret_mode_enabled = False + + idx = entry_mgr.add_nostr_key("main") + npub, _ = entry_mgr.get_nostr_key_pair(idx, TEST_SEED) + + # Modify index to use uppercase type/kind + data = enc_mgr.load_json_data(entry_mgr.index_file) + data["entries"][str(idx)]["type"] = "NOSTR" + data["entries"][str(idx)]["kind"] = "NOSTR" + enc_mgr.save_json_data(data, entry_mgr.index_file) + entry_mgr._index_cache = None + + inputs = iter([str(idx), "q", "p", ""]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) + called = [] + monkeypatch.setattr( + "password_manager.manager.TotpManager.print_qr_code", + lambda data: called.append(data), + ) + + pm.handle_retrieve_entry() + assert called == [f"nostr:{npub}"] From 000a607bbc738a700bec16ae340c4a9df139ead3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 20:45:17 -0400 Subject: [PATCH 04/16] fix qr menu pause --- src/password_manager/manager.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index f0216d3..7bcab96 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1918,6 +1918,7 @@ class PasswordManager: self._entry_edit_menu(index, entry) elif choice == "q": self._entry_qr_menu(index, entry) + pause() else: print(colored("Invalid choice.", "red")) entry = self.entry_manager.retrieve_entry(index) or entry @@ -2003,6 +2004,7 @@ class PasswordManager: from password_manager.seedqr import encode_seedqr TotpManager.print_qr_code(encode_seedqr(seed)) + pause() return if entry_type == EntryType.NOSTR.value: @@ -2038,6 +2040,7 @@ class PasswordManager: TotpManager.print_qr_code(nsec) else: print(colored("Invalid choice.", "red")) + pause() entry = self.entry_manager.retrieve_entry(index) or entry return From c946f30258b82a415c28def3b3317d0914cf4d24 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 21:07:05 -0400 Subject: [PATCH 05/16] Fix entry details display --- src/password_manager/manager.py | 19 ++----------------- src/tests/test_manager_list_entries.py | 6 +++--- src/tests/test_manager_search_display.py | 6 +++--- 3 files changed, 8 insertions(+), 23 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 7bcab96..1751609 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1792,24 +1792,9 @@ class PasswordManager: 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.""" + """Display entry details for ``index`` without prompting.""" - original_input = builtins.input - first_call = True - - def patched_input(prompt: str = "") -> str: - nonlocal first_call - if first_call: - first_call = False - return str(index) - return original_input(prompt) - - try: - builtins.input = patched_input - self.handle_retrieve_entry() - finally: - builtins.input = original_input + self.display_entry_details(index) def _prompt_toggle_archive(self, entry: dict, index: int) -> None: """Prompt the user to archive or restore ``entry`` based on its status.""" diff --git a/src/tests/test_manager_list_entries.py b/src/tests/test_manager_list_entries.py index 46d1f6d..107594d 100644 --- a/src/tests/test_manager_list_entries.py +++ b/src/tests/test_manager_list_entries.py @@ -80,12 +80,12 @@ def test_list_entries_show_details(monkeypatch, capsys): lambda *a, **k: "b", ) - inputs = iter(["1", "0", "n"]) + inputs = iter(["1", "0"]) monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) pm.handle_list_entries() out = capsys.readouterr().out - assert "Retrieved 2FA Code" in out - assert "123456" in out + assert "Label: Example" in out + assert "Period: 30s" in out assert "API" in out assert "acct" in out diff --git a/src/tests/test_manager_search_display.py b/src/tests/test_manager_search_display.py index 3133c65..5116ae2 100644 --- a/src/tests/test_manager_search_display.py +++ b/src/tests/test_manager_search_display.py @@ -41,11 +41,11 @@ def test_search_entries_prompt_for_details(monkeypatch, capsys): monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b") - inputs = iter(["Example", "0", "n", ""]) + inputs = iter(["Example", "0"]) monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) pm.handle_search_entries() out = capsys.readouterr().out assert "0. Example" in out - assert "Retrieved 2FA Code" in out - assert "123456" in out + assert "Label: Example" in out + assert "Period: 30s" in out From 0b4eec55a044ce61df3704160a94dee68acfc8a1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 21:46:31 -0400 Subject: [PATCH 06/16] Pause after displaying entry details --- src/password_manager/manager.py | 20 +++----------------- src/tests/test_manager_list_entries.py | 6 +++--- src/tests/test_manager_search_display.py | 6 +++--- 3 files changed, 9 insertions(+), 23 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 7bcab96..f6b087c 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1792,24 +1792,10 @@ class PasswordManager: 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.""" + """Display entry details for ``index`` without prompting.""" - original_input = builtins.input - first_call = True - - def patched_input(prompt: str = "") -> str: - nonlocal first_call - if first_call: - first_call = False - return str(index) - return original_input(prompt) - - try: - builtins.input = patched_input - self.handle_retrieve_entry() - finally: - builtins.input = original_input + self.display_entry_details(index) + pause() def _prompt_toggle_archive(self, entry: dict, index: int) -> None: """Prompt the user to archive or restore ``entry`` based on its status.""" diff --git a/src/tests/test_manager_list_entries.py b/src/tests/test_manager_list_entries.py index 46d1f6d..107594d 100644 --- a/src/tests/test_manager_list_entries.py +++ b/src/tests/test_manager_list_entries.py @@ -80,12 +80,12 @@ def test_list_entries_show_details(monkeypatch, capsys): lambda *a, **k: "b", ) - inputs = iter(["1", "0", "n"]) + inputs = iter(["1", "0"]) monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) pm.handle_list_entries() out = capsys.readouterr().out - assert "Retrieved 2FA Code" in out - assert "123456" in out + assert "Label: Example" in out + assert "Period: 30s" in out assert "API" in out assert "acct" in out diff --git a/src/tests/test_manager_search_display.py b/src/tests/test_manager_search_display.py index 3133c65..5116ae2 100644 --- a/src/tests/test_manager_search_display.py +++ b/src/tests/test_manager_search_display.py @@ -41,11 +41,11 @@ def test_search_entries_prompt_for_details(monkeypatch, capsys): monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b") - inputs = iter(["Example", "0", "n", ""]) + inputs = iter(["Example", "0"]) monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) pm.handle_search_entries() out = capsys.readouterr().out assert "0. Example" in out - assert "Retrieved 2FA Code" in out - assert "123456" in out + assert "Label: Example" in out + assert "Period: 30s" in out From 8fca2b3346e3ed368ee65bdc6030fcf05e6f879f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 22:16:54 -0400 Subject: [PATCH 07/16] Add entry details workflow and tests --- src/password_manager/manager.py | 19 ++++++++++++- src/tests/test_manager_list_entries.py | 39 ++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index f6b087c..516f820 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1793,8 +1793,25 @@ class PasswordManager: def show_entry_details_by_index(self, index: int) -> None: """Display entry details for ``index`` without prompting.""" + try: + entry = self.entry_manager.retrieve_entry(index) + if not entry: + return - self.display_entry_details(index) + fp, parent_fp, child_fp = self.header_fingerprint_args + clear_header_with_notification( + self, + fp, + "Entry Details", + parent_fingerprint=parent_fp, + child_fingerprint=child_fp, + ) + + self.display_entry_details(index) + self._entry_actions_menu(index, entry) + except Exception as e: + logging.error(f"Failed to display entry details: {e}", exc_info=True) + print(colored(f"Error: Failed to display entry details: {e}", "red")) pause() def _prompt_toggle_archive(self, entry: dict, index: int) -> None: diff --git a/src/tests/test_manager_list_entries.py b/src/tests/test_manager_list_entries.py index 107594d..f309deb 100644 --- a/src/tests/test_manager_list_entries.py +++ b/src/tests/test_manager_list_entries.py @@ -89,3 +89,42 @@ def test_list_entries_show_details(monkeypatch, capsys): assert "Period: 30s" in out assert "API" in out assert "acct" in out + + +def test_show_entry_details_by_index(monkeypatch): + """Ensure entry details screen triggers expected calls.""" + 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 + + index = entry_mgr.add_entry("example.com", 12) + + header_calls = [] + monkeypatch.setattr( + "password_manager.manager.clear_header_with_notification", + lambda *a, **k: header_calls.append(True), + ) + action_calls = [] + monkeypatch.setattr( + pm, + "_entry_actions_menu", + lambda *a, **k: action_calls.append(True), + ) + monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None) + + pm.show_entry_details_by_index(index) + + assert len(header_calls) == 1 + assert len(action_calls) == 1 From 4d559d03391b85e65704ee537044679c6b11f44f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 14 Jul 2025 22:34:04 -0400 Subject: [PATCH 08/16] pause after showing entry details --- src/password_manager/manager.py | 3 ++- src/tests/test_manager_list_entries.py | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 516f820..0c2eafb 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1792,7 +1792,7 @@ class PasswordManager: pause() def show_entry_details_by_index(self, index: int) -> None: - """Display entry details for ``index`` without prompting.""" + """Display details for entry ``index`` and offer actions.""" try: entry = self.entry_manager.retrieve_entry(index) if not entry: @@ -1808,6 +1808,7 @@ class PasswordManager: ) self.display_entry_details(index) + pause() self._entry_actions_menu(index, entry) except Exception as e: logging.error(f"Failed to display entry details: {e}", exc_info=True) diff --git a/src/tests/test_manager_list_entries.py b/src/tests/test_manager_list_entries.py index f309deb..bc3e762 100644 --- a/src/tests/test_manager_list_entries.py +++ b/src/tests/test_manager_list_entries.py @@ -116,15 +116,21 @@ def test_show_entry_details_by_index(monkeypatch): "password_manager.manager.clear_header_with_notification", lambda *a, **k: header_calls.append(True), ) - action_calls = [] + + call_order = [] + monkeypatch.setattr( + pm, + "display_entry_details", + lambda *a, **k: call_order.append("display"), + ) monkeypatch.setattr( pm, "_entry_actions_menu", - lambda *a, **k: action_calls.append(True), + lambda *a, **k: call_order.append("actions"), ) monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None) pm.show_entry_details_by_index(index) assert len(header_calls) == 1 - assert len(action_calls) == 1 + assert call_order == ["display", "actions"] From fffd287032171a9993c5393a1f57d6c3e1402f59 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 15 Jul 2025 08:58:26 -0400 Subject: [PATCH 09/16] Enhance entry detail display and tests --- src/password_manager/manager.py | 44 +++++++++- src/tests/test_manager_list_entries.py | 116 +++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 4 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 0c2eafb..d60cee9 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -2934,8 +2934,9 @@ class PasswordManager: return etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) - if isinstance(etype, str): - etype = etype.lower() + if isinstance(etype, EntryType): + etype = etype.value + etype = str(etype).lower() print(color_text(f"Index: {index}", "index")) if etype == EntryType.TOTP.value: print(color_text(f" Label: {entry.get('label', '')}", "index")) @@ -2957,9 +2958,13 @@ class PasswordManager: elif etype == EntryType.SEED.value: print(color_text(" Type: Seed Phrase", "index")) print(color_text(f" Label: {entry.get('label', '')}", "index")) - print(color_text(f" Words: {entry.get('words', 24)}", "index")) + words = entry.get("word_count", entry.get("words", 24)) + print(color_text(f" Words: {words}", "index")) print( - color_text(f" Derivation Index: {entry.get('index', index)}", "index") + color_text( + f" Derivation Index: {entry.get('index', index)}", + "index", + ) ) notes = entry.get("notes", "") if notes: @@ -3009,6 +3014,37 @@ class PasswordManager: tags = entry.get("tags", []) if tags: print(color_text(f" Tags: {', '.join(tags)}", "index")) + elif etype == EntryType.KEY_VALUE.value: + print(color_text(" Type: Key/Value", "index")) + print(color_text(f" Label: {entry.get('label', '')}", "index")) + print(color_text(f" Value: {entry.get('value', '')}", "index")) + notes = entry.get("notes", "") + if notes: + print(color_text(f" Notes: {notes}", "index")) + tags = entry.get("tags", []) + if tags: + print(color_text(f" Tags: {', '.join(tags)}", "index")) + blacklisted = entry.get("archived", entry.get("blacklisted", False)) + print(color_text(f" Archived: {'Yes' if blacklisted else 'No'}", "index")) + elif etype == EntryType.MANAGED_ACCOUNT.value: + print(color_text(" Type: Managed Account", "index")) + print(color_text(f" Label: {entry.get('label', '')}", "index")) + words = entry.get("word_count", entry.get("words", 24)) + print(color_text(f" Words: {words}", "index")) + print( + color_text(f" Derivation Index: {entry.get('index', index)}", "index") + ) + fingerprint = entry.get("fingerprint", "") + if fingerprint: + print(color_text(f" Fingerprint: {fingerprint}", "index")) + notes = entry.get("notes", "") + if notes: + print(color_text(f" Notes: {notes}", "index")) + tags = entry.get("tags", []) + if tags: + print(color_text(f" Tags: {', '.join(tags)}", "index")) + blacklisted = entry.get("archived", entry.get("blacklisted", False)) + print(color_text(f" Archived: {'Yes' if blacklisted else 'No'}", "index")) else: website = entry.get("label", entry.get("website", "")) username = entry.get("username", "") diff --git a/src/tests/test_manager_list_entries.py b/src/tests/test_manager_list_entries.py index bc3e762..1801c6a 100644 --- a/src/tests/test_manager_list_entries.py +++ b/src/tests/test_manager_list_entries.py @@ -134,3 +134,119 @@ def test_show_entry_details_by_index(monkeypatch): assert len(header_calls) == 1 assert call_order == ["display", "actions"] + + +def _setup_manager(tmp_path): + 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.parent_seed = TEST_SEED + pm.nostr_client = SimpleNamespace() + pm.fingerprint_dir = tmp_path + pm.secret_mode_enabled = False + return pm, entry_mgr + + +def _detail_common(monkeypatch, pm): + monkeypatch.setattr( + "password_manager.manager.clear_header_with_notification", + lambda *a, **k: None, + ) + monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None) + called = [] + monkeypatch.setattr(pm, "_entry_actions_menu", lambda *a, **k: called.append(True)) + return called + + +def test_show_seed_entry_details(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm, entry_mgr = _setup_manager(tmp_path) + idx = entry_mgr.add_seed("seed", TEST_SEED, words_num=12) + + called = _detail_common(monkeypatch, pm) + + pm.show_entry_details_by_index(idx) + out = capsys.readouterr().out + assert "Type: Seed Phrase" in out + assert "Label: seed" in out + assert "Words: 12" in out + assert f"Derivation Index: {idx}" in out + assert called == [True] + + +def test_show_ssh_entry_details(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm, entry_mgr = _setup_manager(tmp_path) + idx = entry_mgr.add_ssh_key("ssh", TEST_SEED) + + called = _detail_common(monkeypatch, pm) + + pm.show_entry_details_by_index(idx) + out = capsys.readouterr().out + assert "Type: SSH Key" in out + assert "Label: ssh" in out + assert f"Derivation Index: {idx}" in out + assert called == [True] + + +def test_show_pgp_entry_details(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm, entry_mgr = _setup_manager(tmp_path) + idx = entry_mgr.add_pgp_key("pgp", TEST_SEED, user_id="test") + + called = _detail_common(monkeypatch, pm) + + pm.show_entry_details_by_index(idx) + out = capsys.readouterr().out + assert "Type: PGP Key" in out + assert "Label: pgp" in out + assert "Key Type: ed25519" in out + assert "User ID: test" in out + assert f"Derivation Index: {idx}" in out + assert called == [True] + + +def test_show_nostr_entry_details(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm, entry_mgr = _setup_manager(tmp_path) + idx = entry_mgr.add_nostr_key("nostr") + + called = _detail_common(monkeypatch, pm) + + pm.show_entry_details_by_index(idx) + out = capsys.readouterr().out + assert "Type: Nostr Key" in out + assert "Label: nostr" in out + assert f"Derivation Index: {idx}" in out + assert called == [True] + + +def test_show_managed_account_entry_details(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm, entry_mgr = _setup_manager(tmp_path) + idx = entry_mgr.add_managed_account("acct", TEST_SEED) + fp = entry_mgr.retrieve_entry(idx).get("fingerprint") + + called = _detail_common(monkeypatch, pm) + + pm.show_entry_details_by_index(idx) + out = capsys.readouterr().out + assert "Type: Managed Account" in out + assert "Label: acct" in out + assert f"Derivation Index: {idx}" in out + assert "Words: 12" in out + assert fp in out + assert called == [True] From 3bcf3312df29016c527ed5a8630536f2e5e4fac9 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 15 Jul 2025 10:43:06 -0400 Subject: [PATCH 10/16] refactor: extract sensitive entry display helper --- src/password_manager/manager.py | 850 ++++++++++++++++---------------- 1 file changed, 423 insertions(+), 427 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index d60cee9..d7a1ff0 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -153,6 +153,7 @@ class PasswordManager: self.profile_stack: list[tuple[str, Path, str]] = [] self.last_unlock_duration: float | None = None self.verbose_timing: bool = False + self._suppress_entry_actions_menu: bool = False # Initialize the fingerprint manager first self.initialize_fingerprint_manager() @@ -2053,11 +2054,422 @@ class PasswordManager: logging.error(f"Error displaying QR menu: {e}", exc_info=True) print(colored(f"Error: Failed to display QR codes: {e}", "red")) + def display_sensitive_entry_info(self, entry: dict, index: int) -> None: + """Display information for a sensitive entry. + + Parameters + ---------- + entry: dict + Entry data retrieved from the vault. + index: int + Index of the entry being displayed. + """ + + self._suppress_entry_actions_menu = False + + entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) + if isinstance(entry_type, str): + entry_type = entry_type.lower() + + if entry_type == EntryType.TOTP.value: + label = entry.get("label", "") + period = int(entry.get("period", 30)) + notes = entry.get("notes", "") + print(colored(f"Retrieving 2FA code for '{label}'.", "cyan")) + print(colored("Press Enter to return to the menu.", "cyan")) + try: + while True: + code = self.entry_manager.get_totp_code(index, self.parent_seed) + if self.secret_mode_enabled: + copy_to_clipboard(code, self.clipboard_clear_delay) + print( + colored( + f"[+] 2FA code for '{label}' copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored("\n[+] Retrieved 2FA Code:\n", "green")) + print(colored(f"Label: {label}", "cyan")) + imported = "secret" in entry + category = "imported" if imported else "deterministic" + print(color_text(f"Code: {code}", category)) + if notes: + print(colored(f"Notes: {notes}", "cyan")) + tags = entry.get("tags", []) + if tags: + print(colored(f"Tags: {', '.join(tags)}", "cyan")) + remaining = self.entry_manager.get_totp_time_remaining(index) + exit_loop = False + while remaining > 0: + filled = int(20 * (period - remaining) / period) + bar = "[" + "#" * filled + "-" * (20 - filled) + "]" + sys.stdout.write(f"\r{bar} {remaining:2d}s") + sys.stdout.flush() + try: + user_input = timed_input("", 1) + if ( + user_input.strip() == "" + or user_input.strip().lower() == "b" + ): + exit_loop = True + break + except TimeoutError: + pass + except KeyboardInterrupt: + exit_loop = True + print() + break + remaining -= 1 + sys.stdout.write("\n") + sys.stdout.flush() + if exit_loop: + break + except Exception as e: # pragma: no cover - best effort + logging.error(f"Error generating TOTP code: {e}", exc_info=True) + print(colored(f"Error: Failed to generate TOTP code: {e}", "red")) + return + + if entry_type == EntryType.SSH.value: + notes = entry.get("notes", "") + label = entry.get("label", "") + if not confirm_action( + "WARNING: Displaying SSH keys reveals sensitive information. Continue? (Y/N): " + ): + self.notify("SSH key display cancelled.", level="WARNING") + return + try: + priv_pem, pub_pem = self.entry_manager.get_ssh_key_pair( + index, self.parent_seed + ) + print(colored("\n[+] Retrieved SSH Key Pair:\n", "green")) + if label: + print(colored(f"Label: {label}", "cyan")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) + tags = entry.get("tags", []) + if tags: + print(colored(f"Tags: {', '.join(tags)}", "cyan")) + print(colored("Public Key:", "cyan")) + print(color_text(pub_pem, "default")) + if self.secret_mode_enabled: + copy_to_clipboard(priv_pem, self.clipboard_clear_delay) + print( + colored( + f"[+] SSH private key copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored("Private Key:", "cyan")) + print(color_text(priv_pem, "deterministic")) + except Exception as e: # pragma: no cover - best effort + logging.error(f"Error deriving SSH key pair: {e}", exc_info=True) + print(colored(f"Error: Failed to derive SSH keys: {e}", "red")) + return + + if entry_type == EntryType.SEED.value: + notes = entry.get("notes", "") + label = entry.get("label", "") + if not confirm_action( + "WARNING: Displaying the seed phrase reveals sensitive information. Continue? (Y/N): " + ): + self.notify("Seed phrase display cancelled.", level="WARNING") + return + try: + phrase = self.entry_manager.get_seed_phrase(index, self.parent_seed) + print(colored("\n[+] Retrieved Seed Phrase:\n", "green")) + print(colored(f"Index: {index}", "cyan")) + if label: + print(colored(f"Label: {label}", "cyan")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) + tags = entry.get("tags", []) + if tags: + print(colored(f"Tags: {', '.join(tags)}", "cyan")) + if self.secret_mode_enabled: + copy_to_clipboard(phrase, 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(phrase, "deterministic")) + if confirm_action("Show derived entropy as hex? (Y/N): "): + from local_bip85.bip85 import BIP85 + from bip_utils import Bip39SeedGenerator + + words = int(entry.get("word_count", entry.get("words", 24))) + bytes_len = {12: 16, 18: 24, 24: 32}.get(words, 32) + seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate() + bip85 = BIP85(seed_bytes) + entropy = bip85.derive_entropy( + index=int(entry.get("index", index)), + bytes_len=bytes_len, + app_no=39, + words_len=words, + ) + print(color_text(f"Entropy: {entropy.hex()}", "deterministic")) + except Exception as e: # pragma: no cover - best effort + logging.error(f"Error deriving seed phrase: {e}", exc_info=True) + print(colored(f"Error: Failed to derive seed phrase: {e}", "red")) + return + + if entry_type == EntryType.PGP.value: + notes = entry.get("notes", "") + label = entry.get("user_id", "") + if not confirm_action( + "WARNING: Displaying the PGP key reveals sensitive information. Continue? (Y/N): " + ): + self.notify("PGP key display cancelled.", level="WARNING") + return + try: + priv_key, fingerprint = self.entry_manager.get_pgp_key( + index, self.parent_seed + ) + print(colored("\n[+] Retrieved PGP Key:\n", "green")) + if label: + print(colored(f"User ID: {label}", "cyan")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) + tags = entry.get("tags", []) + if tags: + print(colored(f"Tags: {', '.join(tags)}", "cyan")) + print(colored(f"Fingerprint: {fingerprint}", "cyan")) + if self.secret_mode_enabled: + copy_to_clipboard(priv_key, self.clipboard_clear_delay) + print( + colored( + f"[+] PGP key copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(color_text(priv_key, "deterministic")) + except Exception as e: # pragma: no cover - best effort + logging.error(f"Error deriving PGP key: {e}", exc_info=True) + print(colored(f"Error: Failed to derive PGP key: {e}", "red")) + return + + if entry_type == EntryType.NOSTR.value: + label = entry.get("label", "") + notes = entry.get("notes", "") + try: + npub, nsec = self.entry_manager.get_nostr_key_pair( + index, self.parent_seed + ) + print(colored("\n[+] Retrieved Nostr Keys:\n", "green")) + print(colored(f"Label: {label}", "cyan")) + print(colored(f"npub: {npub}", "cyan")) + if self.secret_mode_enabled: + copy_to_clipboard(nsec, self.clipboard_clear_delay) + print( + colored( + f"[+] nsec copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(color_text(f"nsec: {nsec}", "deterministic")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) + tags = entry.get("tags", []) + if tags: + print(colored(f"Tags: {', '.join(tags)}", "cyan")) + except Exception as e: # pragma: no cover - best effort + logging.error(f"Error deriving Nostr keys: {e}", exc_info=True) + print(colored(f"Error: Failed to derive Nostr keys: {e}", "red")) + return + + if entry_type == EntryType.KEY_VALUE.value: + label = entry.get("label", "") + value = entry.get("value", "") + notes = entry.get("notes", "") + archived = entry.get("archived", False) + print(colored(f"Retrieving value for '{label}'.", "cyan")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) + tags = entry.get("tags", []) + if tags: + print(colored(f"Tags: {', '.join(tags)}", "cyan")) + print( + colored( + f"Archived Status: {'Archived' if archived else 'Active'}", "cyan" + ) + ) + if self.secret_mode_enabled: + copy_to_clipboard(value, self.clipboard_clear_delay) + print( + colored( + f"[+] Value copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(color_text(f"Value: {value}", "deterministic")) + + custom_fields = entry.get("custom_fields", []) + if custom_fields: + print(colored("Additional Fields:", "cyan")) + hidden_fields = [] + for field in custom_fields: + f_label = field.get("label", "") + f_value = field.get("value", "") + if field.get("is_hidden"): + hidden_fields.append((f_label, f_value)) + print(colored(f" {f_label}: [hidden]", "cyan")) + else: + print(colored(f" {f_label}: {f_value}", "cyan")) + if hidden_fields: + show = input("Reveal hidden fields? (y/N): ").strip().lower() + if show == "y": + for f_label, f_value in hidden_fields: + if self.secret_mode_enabled: + copy_to_clipboard(f_value, self.clipboard_clear_delay) + print( + colored( + f"[+] {f_label} copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored(f" {f_label}: {f_value}", "cyan")) + 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")) + tags = entry.get("tags", []) + if tags: + print(colored(f"Tags: {', '.join(tags)}", "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")) + return + if action == "l": + self._suppress_entry_actions_menu = True + self.load_managed_account(index) + return + return + + # Default: PASSWORD + website_name = entry.get("label", entry.get("website")) + length = entry.get("length") + username = entry.get("username") + url = entry.get("url") + blacklisted = entry.get("archived", entry.get("blacklisted")) + notes = entry.get("notes", "") + + print( + colored( + f"Retrieving password for '{website_name}' with length {length}.", + "cyan", + ) + ) + if username: + print(colored(f"Username: {username}", "cyan")) + if url: + print(colored(f"URL: {url}", "cyan")) + if blacklisted: + self.notify( + "Warning: This password is archived and should not be used.", + level="WARNING", + ) + + password = self.password_generator.generate_password(length, index) + + if password: + if self.secret_mode_enabled: + copy_to_clipboard(password, self.clipboard_clear_delay) + print( + colored( + f"[+] Password for '{website_name}' copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print( + colored( + f"\n[+] Retrieved Password for {website_name}:\n", + "green", + ) + ) + print(color_text(f"Password: {password}", "deterministic")) + print(colored(f"Associated Username: {username or 'N/A'}", "cyan")) + print(colored(f"Associated URL: {url or 'N/A'}", "cyan")) + print( + colored( + f"Archived Status: {'Archived' if blacklisted else 'Active'}", + "cyan", + ) + ) + tags = entry.get("tags", []) + if tags: + print(colored(f"Tags: {', '.join(tags)}", "cyan")) + custom_fields = entry.get("custom_fields", []) + if custom_fields: + print(colored("Additional Fields:", "cyan")) + hidden_fields = [] + for field in custom_fields: + label = field.get("label", "") + value = field.get("value", "") + if field.get("is_hidden"): + hidden_fields.append((label, value)) + print(colored(f" {label}: [hidden]", "cyan")) + else: + print(colored(f" {label}: {value}", "cyan")) + if hidden_fields: + show = input("Reveal hidden fields? (y/N): ").strip().lower() + if show == "y": + for label, value in hidden_fields: + if self.secret_mode_enabled: + copy_to_clipboard(value, self.clipboard_clear_delay) + print( + colored( + f"[+] {label} copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored(f" {label}: {value}", "cyan")) + else: + print(colored("Error: Failed to retrieve the password.", "red")) + return + def handle_retrieve_entry(self) -> None: - """ - Handles retrieving a password from the index by prompting the user for the index number - and displaying the corresponding password and associated details. - """ + """Prompt for an index and display the corresponding entry.""" try: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( @@ -2081,431 +2493,15 @@ class PasswordManager: pause() return - entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) - if isinstance(entry_type, str): - entry_type = entry_type.lower() + self.display_sensitive_entry_info(entry, index) + pause() - if entry_type == EntryType.TOTP.value: - label = entry.get("label", "") - period = int(entry.get("period", 30)) - notes = entry.get("notes", "") - print(colored(f"Retrieving 2FA code for '{label}'.", "cyan")) - print(colored("Press Enter to return to the menu.", "cyan")) - try: - while True: - code = self.entry_manager.get_totp_code(index, self.parent_seed) - if self.secret_mode_enabled: - copy_to_clipboard(code, self.clipboard_clear_delay) - print( - colored( - f"[+] 2FA code for '{label}' copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", - ) - ) - else: - print(colored("\n[+] Retrieved 2FA Code:\n", "green")) - print(colored(f"Label: {label}", "cyan")) - imported = "secret" in entry - category = "imported" if imported else "deterministic" - print(color_text(f"Code: {code}", category)) - if notes: - print(colored(f"Notes: {notes}", "cyan")) - tags = entry.get("tags", []) - if tags: - print(colored(f"Tags: {', '.join(tags)}", "cyan")) - remaining = self.entry_manager.get_totp_time_remaining(index) - exit_loop = False - while remaining > 0: - filled = int(20 * (period - remaining) / period) - bar = "[" + "#" * filled + "-" * (20 - filled) + "]" - sys.stdout.write(f"\r{bar} {remaining:2d}s") - sys.stdout.flush() - try: - user_input = timed_input("", 1) - if ( - user_input.strip() == "" - or user_input.strip().lower() == "b" - ): - exit_loop = True - break - except TimeoutError: - pass - except KeyboardInterrupt: - exit_loop = True - print() - break - remaining -= 1 - sys.stdout.write("\n") - sys.stdout.flush() - if exit_loop: - break - except Exception as e: - logging.error(f"Error generating TOTP code: {e}", exc_info=True) - print(colored(f"Error: Failed to generate TOTP code: {e}", "red")) - self._entry_actions_menu(index, entry) - pause() - return - if entry_type == EntryType.SSH.value: - notes = entry.get("notes", "") - label = entry.get("label", "") - if not confirm_action( - "WARNING: Displaying SSH keys reveals sensitive information. Continue? (Y/N): " - ): - self.notify("SSH key display cancelled.", level="WARNING") - return - try: - priv_pem, pub_pem = self.entry_manager.get_ssh_key_pair( - index, self.parent_seed - ) - print(colored("\n[+] Retrieved SSH Key Pair:\n", "green")) - if label: - print(colored(f"Label: {label}", "cyan")) - if notes: - print(colored(f"Notes: {notes}", "cyan")) - tags = entry.get("tags", []) - if tags: - print(colored(f"Tags: {', '.join(tags)}", "cyan")) - print(colored("Public Key:", "cyan")) - print(color_text(pub_pem, "default")) - if self.secret_mode_enabled: - copy_to_clipboard(priv_pem, self.clipboard_clear_delay) - print( - colored( - f"[+] SSH private key copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", - ) - ) - else: - print(colored("Private Key:", "cyan")) - print(color_text(priv_pem, "deterministic")) - except Exception as e: - logging.error(f"Error deriving SSH key pair: {e}", exc_info=True) - print(colored(f"Error: Failed to derive SSH keys: {e}", "red")) - pause() - self._entry_actions_menu(index, entry) - pause() - return - if entry_type == EntryType.SEED.value: - notes = entry.get("notes", "") - label = entry.get("label", "") - if not confirm_action( - "WARNING: Displaying the seed phrase reveals sensitive information. Continue? (Y/N): " - ): - self.notify("Seed phrase display cancelled.", level="WARNING") - return - try: - phrase = self.entry_manager.get_seed_phrase(index, self.parent_seed) - print(colored("\n[+] Retrieved Seed Phrase:\n", "green")) - print(colored(f"Index: {index}", "cyan")) - if label: - print(colored(f"Label: {label}", "cyan")) - if notes: - print(colored(f"Notes: {notes}", "cyan")) - tags = entry.get("tags", []) - if tags: - print(colored(f"Tags: {', '.join(tags)}", "cyan")) - if self.secret_mode_enabled: - copy_to_clipboard(phrase, 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(phrase, "deterministic")) - # Removed QR code display prompt and output - if confirm_action("Show derived entropy as hex? (Y/N): "): - from local_bip85.bip85 import BIP85 - from bip_utils import Bip39SeedGenerator - - words = int(entry.get("word_count", entry.get("words", 24))) - bytes_len = {12: 16, 18: 24, 24: 32}.get(words, 32) - seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate() - bip85 = BIP85(seed_bytes) - entropy = bip85.derive_entropy( - index=int(entry.get("index", index)), - bytes_len=bytes_len, - app_no=39, - words_len=words, - ) - print(color_text(f"Entropy: {entropy.hex()}", "deterministic")) - except Exception as e: - logging.error(f"Error deriving seed phrase: {e}", exc_info=True) - print(colored(f"Error: Failed to derive seed phrase: {e}", "red")) - pause() - self._entry_actions_menu(index, entry) - pause() - return - if entry_type == EntryType.PGP.value: - notes = entry.get("notes", "") - label = entry.get("user_id", "") - if not confirm_action( - "WARNING: Displaying the PGP key reveals sensitive information. Continue? (Y/N): " - ): - self.notify("PGP key display cancelled.", level="WARNING") - return - try: - priv_key, fingerprint = self.entry_manager.get_pgp_key( - index, self.parent_seed - ) - print(colored("\n[+] Retrieved PGP Key:\n", "green")) - if label: - print(colored(f"User ID: {label}", "cyan")) - if notes: - print(colored(f"Notes: {notes}", "cyan")) - tags = entry.get("tags", []) - if tags: - print(colored(f"Tags: {', '.join(tags)}", "cyan")) - print(colored(f"Fingerprint: {fingerprint}", "cyan")) - if self.secret_mode_enabled: - copy_to_clipboard(priv_key, self.clipboard_clear_delay) - print( - colored( - f"[+] PGP key copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", - ) - ) - else: - print(color_text(priv_key, "deterministic")) - except Exception as e: - logging.error(f"Error deriving PGP key: {e}", exc_info=True) - print(colored(f"Error: Failed to derive PGP key: {e}", "red")) - pause() - self._entry_actions_menu(index, entry) - pause() - return - if entry_type == EntryType.NOSTR.value: - label = entry.get("label", "") - notes = entry.get("notes", "") - try: - npub, nsec = self.entry_manager.get_nostr_key_pair( - index, self.parent_seed - ) - print(colored("\n[+] Retrieved Nostr Keys:\n", "green")) - print(colored(f"Label: {label}", "cyan")) - print(colored(f"npub: {npub}", "cyan")) - if self.secret_mode_enabled: - copy_to_clipboard(nsec, self.clipboard_clear_delay) - print( - colored( - f"[+] nsec copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", - ) - ) - else: - print(color_text(f"nsec: {nsec}", "deterministic")) - # QR code display removed for npub and nsec - if notes: - print(colored(f"Notes: {notes}", "cyan")) - tags = entry.get("tags", []) - if tags: - print(colored(f"Tags: {', '.join(tags)}", "cyan")) - except Exception as e: - logging.error(f"Error deriving Nostr keys: {e}", exc_info=True) - print(colored(f"Error: Failed to derive Nostr keys: {e}", "red")) - pause() - self._entry_actions_menu(index, entry) - pause() + if getattr(self, "_suppress_entry_actions_menu", False): return - if entry_type == EntryType.KEY_VALUE.value: - label = entry.get("label", "") - value = entry.get("value", "") - notes = entry.get("notes", "") - archived = entry.get("archived", False) - print(colored(f"Retrieving value for '{label}'.", "cyan")) - if notes: - print(colored(f"Notes: {notes}", "cyan")) - tags = entry.get("tags", []) - if tags: - print(colored(f"Tags: {', '.join(tags)}", "cyan")) - print( - colored( - f"Archived Status: {'Archived' if archived else 'Active'}", - "cyan", - ) - ) - if self.secret_mode_enabled: - copy_to_clipboard(value, self.clipboard_clear_delay) - print( - colored( - f"[+] Value copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", - ) - ) - else: - print(color_text(f"Value: {value}", "deterministic")) - - custom_fields = entry.get("custom_fields", []) - if custom_fields: - print(colored("Additional Fields:", "cyan")) - hidden_fields = [] - for field in custom_fields: - f_label = field.get("label", "") - f_value = field.get("value", "") - if field.get("is_hidden"): - hidden_fields.append((f_label, f_value)) - print(colored(f" {f_label}: [hidden]", "cyan")) - else: - print(colored(f" {f_label}: {f_value}", "cyan")) - if hidden_fields: - show = input("Reveal hidden fields? (y/N): ").strip().lower() - if show == "y": - for f_label, f_value in hidden_fields: - if self.secret_mode_enabled: - copy_to_clipboard( - f_value, self.clipboard_clear_delay - ) - print( - colored( - f"[+] {f_label} copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", - ) - ) - else: - print(colored(f" {f_label}: {f_value}", "cyan")) - self._entry_actions_menu(index, entry) - 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")) - tags = entry.get("tags", []) - if tags: - print(colored(f"Tags: {', '.join(tags)}", "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")) - # QR code display removed for managed account seed - self._entry_actions_menu(index, entry) - pause() - return - if action == "l": - self.load_managed_account(index) - return - self._entry_actions_menu(index, entry) - pause() - return - - website_name = entry.get("label", entry.get("website")) - length = entry.get("length") - username = entry.get("username") - url = entry.get("url") - blacklisted = entry.get("archived", entry.get("blacklisted")) - notes = entry.get("notes", "") - - print( - colored( - f"Retrieving password for '{website_name}' with length {length}.", - "cyan", - ) - ) - if username: - print(colored(f"Username: {username}", "cyan")) - if url: - print(colored(f"URL: {url}", "cyan")) - if blacklisted: - self.notify( - "Warning: This password is archived and should not be used.", - level="WARNING", - ) - - password = self.password_generator.generate_password(length, index) - - if password: - if self.secret_mode_enabled: - copy_to_clipboard(password, self.clipboard_clear_delay) - print( - colored( - f"[+] Password for '{website_name}' copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", - ) - ) - else: - print( - colored( - f"\n[+] Retrieved Password for {website_name}:\n", - "green", - ) - ) - print(color_text(f"Password: {password}", "deterministic")) - print(colored(f"Associated Username: {username or 'N/A'}", "cyan")) - print(colored(f"Associated URL: {url or 'N/A'}", "cyan")) - print( - colored( - f"Archived Status: {'Archived' if blacklisted else 'Active'}", - "cyan", - ) - ) - tags = entry.get("tags", []) - if tags: - print(colored(f"Tags: {', '.join(tags)}", "cyan")) - custom_fields = entry.get("custom_fields", []) - if custom_fields: - print(colored("Additional Fields:", "cyan")) - hidden_fields = [] - for field in custom_fields: - label = field.get("label", "") - value = field.get("value", "") - if field.get("is_hidden"): - hidden_fields.append((label, value)) - print(colored(f" {label}: [hidden]", "cyan")) - else: - print(colored(f" {label}: {value}", "cyan")) - if hidden_fields: - show = ( - input("Reveal hidden fields? (y/N): ").strip().lower() - ) - if show == "y": - for label, value in hidden_fields: - if self.secret_mode_enabled: - copy_to_clipboard( - value, self.clipboard_clear_delay - ) - print( - colored( - f"[+] {label} copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", - ) - ) - else: - print(colored(f" {label}: {value}", "cyan")) - else: - print(colored("Error: Failed to retrieve the password.", "red")) self._entry_actions_menu(index, entry) pause() + return except Exception as e: logging.error(f"Error during password retrieval: {e}", exc_info=True) print(colored(f"Error: Failed to retrieve password: {e}", "red")) @@ -3914,9 +3910,9 @@ class PasswordManager: ) stats["backup_count"] = len(backups) stats["backup_dir"] = str(self.backup_manager.backup_dir) - stats["additional_backup_path"] = ( - self.config_manager.get_additional_backup_path() - ) + stats[ + "additional_backup_path" + ] = self.config_manager.get_additional_backup_path() # Nostr sync info manifest = getattr(self.nostr_client, "current_manifest", None) From 6d24ffb2ec01bb68a1cfef9763035d92e4b45637 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 15 Jul 2025 10:53:50 -0400 Subject: [PATCH 11/16] refactor: streamline retrieve flow --- src/password_manager/manager.py | 11 +++-------- src/tests/test_retrieve_pause_sensitive_entries.py | 2 +- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index d7a1ff0..485c6bb 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -2494,11 +2494,6 @@ class PasswordManager: return self.display_sensitive_entry_info(entry, index) - pause() - - if getattr(self, "_suppress_entry_actions_menu", False): - return - self._entry_actions_menu(index, entry) pause() return @@ -3910,9 +3905,9 @@ class PasswordManager: ) stats["backup_count"] = len(backups) stats["backup_dir"] = str(self.backup_manager.backup_dir) - stats[ - "additional_backup_path" - ] = self.config_manager.get_additional_backup_path() + stats["additional_backup_path"] = ( + self.config_manager.get_additional_backup_path() + ) # Nostr sync info manifest = getattr(self.nostr_client, "current_manifest", None) diff --git a/src/tests/test_retrieve_pause_sensitive_entries.py b/src/tests/test_retrieve_pause_sensitive_entries.py index 4d48cd5..dcb719b 100644 --- a/src/tests/test_retrieve_pause_sensitive_entries.py +++ b/src/tests/test_retrieve_pause_sensitive_entries.py @@ -55,4 +55,4 @@ def test_pause_before_entry_actions(monkeypatch, adder, needs_confirm): ) pm.handle_retrieve_entry() - assert len(pause_calls) == 2 + assert len(pause_calls) == 1 From 3de84ec4843511e761159dc360c8942eabe441ec Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 15 Jul 2025 11:02:21 -0400 Subject: [PATCH 12/16] feat: prompt sensitive view in entry details --- src/password_manager/manager.py | 4 ++++ src/tests/test_manager_list_entries.py | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 485c6bb..2c79516 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1809,6 +1809,10 @@ class PasswordManager: ) self.display_entry_details(index) + + if confirm_action("Show sensitive information? (y/N): "): + self.display_sensitive_entry_info(entry, index) + pause() self._entry_actions_menu(index, entry) except Exception as e: diff --git a/src/tests/test_manager_list_entries.py b/src/tests/test_manager_list_entries.py index 1801c6a..4abd9f0 100644 --- a/src/tests/test_manager_list_entries.py +++ b/src/tests/test_manager_list_entries.py @@ -129,6 +129,9 @@ def test_show_entry_details_by_index(monkeypatch): lambda *a, **k: call_order.append("actions"), ) monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None) + monkeypatch.setattr( + "password_manager.manager.confirm_action", lambda *a, **k: False + ) pm.show_entry_details_by_index(index) @@ -161,6 +164,9 @@ def _detail_common(monkeypatch, pm): lambda *a, **k: None, ) monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None) + monkeypatch.setattr( + "password_manager.manager.confirm_action", lambda *a, **k: False + ) called = [] monkeypatch.setattr(pm, "_entry_actions_menu", lambda *a, **k: called.append(True)) return called From 754dce086cc4c47715ffcb219dcd730f49690582 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 15 Jul 2025 11:17:13 -0400 Subject: [PATCH 13/16] Show more fingerprints --- src/password_manager/manager.py | 14 ++++++++++++++ src/tests/test_manager_list_entries.py | 8 ++++++++ 2 files changed, 22 insertions(+) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 2c79516..76ab937 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -2973,6 +2973,12 @@ class PasswordManager: print( color_text(f" Derivation Index: {entry.get('index', index)}", "index") ) + pub_label = entry.get("public_key_label", "") + if pub_label: + print(color_text(f" Public Key Label: {pub_label}", "index")) + ssh_fingerprint = entry.get("fingerprint", "") + if ssh_fingerprint: + print(color_text(f" Fingerprint: {ssh_fingerprint}", "index")) notes = entry.get("notes", "") if notes: print(color_text(f" Notes: {notes}", "index")) @@ -2991,6 +2997,14 @@ class PasswordManager: print( color_text(f" Derivation Index: {entry.get('index', index)}", "index") ) + try: + _priv, pgp_fp = self.entry_manager.get_pgp_key(index, self.parent_seed) + if pgp_fp: + print(color_text(f" Fingerprint: {pgp_fp}", "index")) + except Exception as pgp_err: # pragma: no cover - best effort logging + logging.error( + f"Failed to derive PGP fingerprint: {pgp_err}", exc_info=True + ) notes = entry.get("notes", "") if notes: print(color_text(f" Notes: {notes}", "index")) diff --git a/src/tests/test_manager_list_entries.py b/src/tests/test_manager_list_entries.py index 4abd9f0..5b603dc 100644 --- a/src/tests/test_manager_list_entries.py +++ b/src/tests/test_manager_list_entries.py @@ -194,6 +194,10 @@ def test_show_ssh_entry_details(monkeypatch, capsys): tmp_path = Path(tmpdir) pm, entry_mgr = _setup_manager(tmp_path) idx = entry_mgr.add_ssh_key("ssh", TEST_SEED) + data = entry_mgr._load_index(force_reload=True) + data["entries"][str(idx)]["public_key_label"] = "server" + data["entries"][str(idx)]["fingerprint"] = "abc123" + entry_mgr._save_index(data) called = _detail_common(monkeypatch, pm) @@ -202,6 +206,8 @@ def test_show_ssh_entry_details(monkeypatch, capsys): assert "Type: SSH Key" in out assert "Label: ssh" in out assert f"Derivation Index: {idx}" in out + assert "server" in out + assert "abc123" in out assert called == [True] @@ -210,6 +216,7 @@ def test_show_pgp_entry_details(monkeypatch, capsys): tmp_path = Path(tmpdir) pm, entry_mgr = _setup_manager(tmp_path) idx = entry_mgr.add_pgp_key("pgp", TEST_SEED, user_id="test") + _k, fp = entry_mgr.get_pgp_key(idx, TEST_SEED) called = _detail_common(monkeypatch, pm) @@ -220,6 +227,7 @@ def test_show_pgp_entry_details(monkeypatch, capsys): assert "Key Type: ed25519" in out assert "User ID: test" in out assert f"Derivation Index: {idx}" in out + assert fp in out assert called == [True] From dfa560a270c0df199399b097095e08933bdf5ac0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 15 Jul 2025 11:45:54 -0400 Subject: [PATCH 14/16] Add parameterized sensitive entry display test --- src/tests/test_manager_list_entries.py | 92 ++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/src/tests/test_manager_list_entries.py b/src/tests/test_manager_list_entries.py index 5b603dc..c9289a3 100644 --- a/src/tests/test_manager_list_entries.py +++ b/src/tests/test_manager_list_entries.py @@ -2,6 +2,8 @@ from pathlib import Path from tempfile import TemporaryDirectory from types import SimpleNamespace +import pytest + from helpers import create_vault, TEST_SEED, TEST_PASSWORD import sys @@ -264,3 +266,93 @@ def test_show_managed_account_entry_details(monkeypatch, capsys): assert "Words: 12" in out assert fp in out assert called == [True] + + +@pytest.mark.parametrize( + "entry_type", + [ + "password", + "seed", + "ssh", + "pgp", + "nostr", + "totp", + "key_value", + "managed_account", + ], +) +def test_show_entry_details_sensitive(monkeypatch, capsys, entry_type): + """Ensure sensitive details are displayed for each entry type.""" + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm, entry_mgr = _setup_manager(tmp_path) + pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw123") + + monkeypatch.setattr( + "password_manager.manager.confirm_action", lambda *a, **k: True + ) + monkeypatch.setattr( + "password_manager.manager.copy_to_clipboard", lambda *a, **k: None + ) + monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b") + monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) + monkeypatch.setattr( + "password_manager.manager.TotpManager.print_qr_code", lambda *a, **k: None + ) + monkeypatch.setattr( + "password_manager.manager.clear_header_with_notification", + lambda *a, **k: None, + ) + monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None) + + input_val = "r" if entry_type == "managed_account" else "" + monkeypatch.setattr("builtins.input", lambda *a, **k: input_val) + + called = [] + monkeypatch.setattr( + pm, "_entry_actions_menu", lambda *a, **k: called.append(True) + ) + + if entry_type == "password": + idx = entry_mgr.add_entry("example", 8) + expected = "pw123" + elif entry_type == "seed": + idx = entry_mgr.add_seed("seed", TEST_SEED, words_num=12) + expected = entry_mgr.get_seed_phrase(idx, TEST_SEED) + elif entry_type == "ssh": + idx = entry_mgr.add_ssh_key("ssh", TEST_SEED) + priv, pub = entry_mgr.get_ssh_key_pair(idx, TEST_SEED) + expected = priv + extra = pub + elif entry_type == "pgp": + idx = entry_mgr.add_pgp_key("pgp", TEST_SEED, user_id="test") + priv, fp = entry_mgr.get_pgp_key(idx, TEST_SEED) + expected = priv + extra = fp + elif entry_type == "nostr": + idx = entry_mgr.add_nostr_key("nostr") + _npub, nsec = entry_mgr.get_nostr_key_pair(idx, TEST_SEED) + expected = nsec + elif entry_type == "totp": + entry_mgr.add_totp("Example", TEST_SEED) + idx = 0 + monkeypatch.setattr( + pm.entry_manager, "get_totp_code", lambda *a, **k: "123456" + ) + monkeypatch.setattr( + pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1 + ) + expected = "123456" + elif entry_type == "key_value": + idx = entry_mgr.add_key_value("API", "abc") + expected = "abc" + else: # managed_account + idx = entry_mgr.add_managed_account("acct", TEST_SEED) + expected = entry_mgr.get_managed_account_seed(idx, TEST_SEED) + + pm.show_entry_details_by_index(idx) + out = capsys.readouterr().out + assert expected in out + if entry_type in {"ssh", "pgp"}: + assert extra in out + assert called == [True] From 3e004d393235d943a7c24f6b3974cc757c875132 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 15 Jul 2025 12:19:15 -0400 Subject: [PATCH 15/16] docs: describe entry detail view --- README.md | 9 +++++++++ docs/docs/content/01-getting-started/01-advanced_cli.md | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/README.md b/README.md index 925223c..a4d9e50 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,15 @@ When **Secret Mode** is enabled, SeedPass copies retrieved passwords directly to 2. Choose how many seconds to keep passwords on the clipboard. 3. Retrieve an entry and SeedPass will confirm the password was copied. +### Viewing Entry Details + +Selecting an item from **List Entries** or **Search Entries** first displays the +entry's metadata such as the label, username, tags and notes. Passwords, seed +phrases and other sensitive fields remain hidden until you choose to reveal +them. When you opt to show the secret, the details view presents the same action +menu as **Retrieve Entry** so you can edit, archive or display QR codes for the +entry. + ### Additional Entry Types SeedPass supports storing more than just passwords and 2FA secrets. You can also create entries for: diff --git a/docs/docs/content/01-getting-started/01-advanced_cli.md b/docs/docs/content/01-getting-started/01-advanced_cli.md index 7fa3768..1c0bed6 100644 --- a/docs/docs/content/01-getting-started/01-advanced_cli.md +++ b/docs/docs/content/01-getting-started/01-advanced_cli.md @@ -156,6 +156,14 @@ $ seedpass entry get "email" Code: 123456 ``` +### Viewing Entry Details + +Picking an entry from `entry list` or `entry search` displays its metadata first +so you can review the label, username and notes. Sensitive fields are hidden +until you confirm you want to reveal them. After showing the secret, the details +view offers the same actions as `entry get`—edit the entry, archive it or show +QR codes for supported types. + ### `vault` Commands - **`seedpass vault export`** – Export the entire vault to an encrypted JSON file. From 5a3b80b4f664ae7a06b2921d3ac601bc9e96b213 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 15 Jul 2025 12:47:01 -0400 Subject: [PATCH 16/16] Handle EntryType objects when loading --- src/password_manager/manager.py | 42 +++++++++++--------------- src/tests/test_manager_list_entries.py | 38 +++++++++++++++++++++++ 2 files changed, 56 insertions(+), 24 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 76ab937..14f64f6 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1837,6 +1837,13 @@ class PasswordManager: self.is_dirty = True self.last_update = time.time() + def _entry_type_str(self, entry: dict) -> str: + """Return the entry type as a lowercase string.""" + entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) + if isinstance(entry_type, EntryType): + entry_type = entry_type.value + return str(entry_type).lower() + def _entry_actions_menu(self, index: int, entry: dict) -> None: """Provide actions for a retrieved entry.""" while True: @@ -1849,9 +1856,7 @@ class PasswordManager: child_fingerprint=child_fp, ) archived = entry.get("archived", entry.get("blacklisted", False)) - entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) - if isinstance(entry_type, str): - entry_type = entry_type.lower() + entry_type = self._entry_type_str(entry) print(colored("\n[+] Entry Actions:", "green")) if archived: print(colored("U. Unarchive", "cyan")) @@ -1934,9 +1939,7 @@ class PasswordManager: def _entry_edit_menu(self, index: int, entry: dict) -> None: """Sub-menu for editing common entry fields.""" - entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) - if isinstance(entry_type, str): - entry_type = entry_type.lower() + entry_type = self._entry_type_str(entry) while True: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( @@ -1996,9 +1999,7 @@ class PasswordManager: def _entry_qr_menu(self, index: int, entry: dict) -> None: """Display QR codes for the given ``entry``.""" - entry_type = entry.get("type", entry.get("kind")) - if isinstance(entry_type, str): - entry_type = entry_type.lower() + entry_type = self._entry_type_str(entry) try: if entry_type in {EntryType.SEED.value, EntryType.MANAGED_ACCOUNT.value}: @@ -2071,9 +2072,7 @@ class PasswordManager: self._suppress_entry_actions_menu = False - entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) - if isinstance(entry_type, str): - entry_type = entry_type.lower() + entry_type = self._entry_type_str(entry) if entry_type == EntryType.TOTP.value: label = entry.get("label", "") @@ -2533,9 +2532,7 @@ class PasswordManager: if not entry: return - entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) - if isinstance(entry_type, str): - entry_type = entry_type.lower() + entry_type = self._entry_type_str(entry) if entry_type == EntryType.TOTP.value: label = entry.get("label", "") @@ -2928,10 +2925,7 @@ class PasswordManager: if not entry: return - etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) - if isinstance(etype, EntryType): - etype = etype.value - etype = str(etype).lower() + etype = self._entry_type_str(entry) print(color_text(f"Index: {index}", "index")) if etype == EntryType.TOTP.value: print(color_text(f" Label: {entry.get('label', '')}", "index")) @@ -3286,7 +3280,9 @@ class PasswordManager: entries = data.get("entries", {}) totp_list: list[tuple[str, int, int, bool]] = [] for idx_str, entry in entries.items(): - if entry.get("type") == EntryType.TOTP.value and not entry.get( + if self._entry_type_str( + entry + ) == EntryType.TOTP.value and not entry.get( "archived", entry.get("blacklisted", False) ): label = entry.get("label", "") @@ -3582,7 +3578,7 @@ class PasswordManager: totp_entries = [] for entry in entries.values(): - if entry.get("type") == EntryType.TOTP.value: + if self._entry_type_str(entry) == EntryType.TOTP.value: label = entry.get("label", "") period = int(entry.get("period", 30)) digits = int(entry.get("digits", 6)) @@ -3878,9 +3874,7 @@ class PasswordManager: entries = data.get("entries", {}) counts: dict[str, int] = {etype.value: 0 for etype in EntryType} for entry in entries.values(): - etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) - if isinstance(etype, str): - etype = etype.lower() + etype = self._entry_type_str(entry) counts[etype] = counts.get(etype, 0) + 1 stats["entries"] = counts stats["total_entries"] = len(entries) diff --git a/src/tests/test_manager_list_entries.py b/src/tests/test_manager_list_entries.py index c9289a3..26ad712 100644 --- a/src/tests/test_manager_list_entries.py +++ b/src/tests/test_manager_list_entries.py @@ -13,6 +13,7 @@ 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.entry_types import EntryType from password_manager.config_manager import ConfigManager @@ -356,3 +357,40 @@ def test_show_entry_details_sensitive(monkeypatch, capsys, entry_type): if entry_type in {"ssh", "pgp"}: assert extra in out assert called == [True] + + +@pytest.mark.parametrize( + "entry_type", [EntryType.PASSWORD, EntryType.TOTP, EntryType.KEY_VALUE] +) +def test_show_entry_details_with_enum_type(monkeypatch, capsys, entry_type): + """Entries storing an EntryType enum should display correctly.""" + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm, entry_mgr = _setup_manager(tmp_path) + + if entry_type == EntryType.PASSWORD: + idx = entry_mgr.add_entry("example.com", 8) + expect = "example.com" + elif entry_type == EntryType.TOTP: + entry_mgr.add_totp("Example", TEST_SEED) + idx = 0 + monkeypatch.setattr( + pm.entry_manager, "get_totp_code", lambda *a, **k: "123456" + ) + monkeypatch.setattr( + pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1 + ) + expect = "Label: Example" + else: # KEY_VALUE + idx = entry_mgr.add_key_value("API", "abc") + expect = "API" + + data = entry_mgr._load_index(force_reload=True) + data["entries"][str(idx)]["type"] = entry_type + entry_mgr._save_index(data) + + called = _detail_common(monkeypatch, pm) + pm.show_entry_details_by_index(idx) + out = capsys.readouterr().out + assert expect in out + assert called == [True]