From e95c60c9d95bc5934355ead22ae83ba70a45b182 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 5 Jul 2025 08:16:13 -0400 Subject: [PATCH 1/9] Document Nostr key QR codes --- README.md | 3 ++ src/password_manager/manager.py | 12 +++++++ src/tests/test_nostr_qr.py | 60 +++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 src/tests/test_nostr_qr.py diff --git a/README.md b/README.md index 519c8419..40262715 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,9 @@ SeedPass supports storing more than just passwords and 2FA secrets. You can also - **Seed Phrase** – generate a BIP-39 mnemonic and keep it encrypted until needed. - **PGP Key** – derive an OpenPGP key pair from your master seed. - **Nostr Key Pair** – store the index used to derive an `npub`/`nsec` pair for Nostr clients. + When you retrieve one of these entries, SeedPass can display QR codes for the + keys. The `npub` is wrapped in the `nostr:` URI scheme so any client can scan + it, while the `nsec` QR is shown only after a security warning. ### Managing Multiple Seeds diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 06b7726b..522f3663 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1147,6 +1147,12 @@ class PasswordManager: ) else: print(colored(f"nsec: {nsec}", "cyan")) + if confirm_action("Show QR code for npub? (Y/N): "): + TotpManager.print_qr_code(f"nostr:{npub}") + if confirm_action( + "WARNING: Displaying the nsec QR reveals your private key. Continue? (Y/N): " + ): + TotpManager.print_qr_code(nsec) try: self.sync_vault() except Exception as nostr_error: # pragma: no cover - best effort @@ -1342,6 +1348,12 @@ class PasswordManager: ) else: print(colored(f"nsec: {nsec}", "cyan")) + if confirm_action("Show QR code for npub? (Y/N): "): + TotpManager.print_qr_code(f"nostr:{npub}") + if confirm_action( + "WARNING: Displaying the nsec QR reveals your private key. Continue? (Y/N): " + ): + TotpManager.print_qr_code(nsec) if notes: print(colored(f"Notes: {notes}", "cyan")) except Exception as e: diff --git a/src/tests/test_nostr_qr.py b/src/tests/test_nostr_qr.py new file mode 100644 index 00000000..fe8cc84d --- /dev/null +++ b/src/tests/test_nostr_qr.py @@ -0,0 +1,60 @@ +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, TotpManager +from password_manager.config_manager import ConfigManager + + +class FakeNostrClient: + def __init__(self, *args, **kwargs): + self.published = [] + + def publish_snapshot(self, data: bytes): + self.published.append(data) + return None, "abcd" + + +def test_show_qr_for_nostr_keys(monkeypatch): + 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) + + monkeypatch.setattr("builtins.input", lambda *a, **k: str(idx)) + responses = iter([True, False]) + monkeypatch.setattr( + "password_manager.manager.confirm_action", + lambda *_a, **_k: next(responses), + ) + 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 0209312d5937d4b7811aaed1a2087d6613b835e7 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 5 Jul 2025 08:34:34 -0400 Subject: [PATCH 2/9] Improve search display for all entry types --- src/main.py | 43 +++++++++--- src/password_manager/manager.py | 89 +++++++++++++++++++++--- src/tests/test_manager_search_display.py | 41 +++++++++++ 3 files changed, 154 insertions(+), 19 deletions(-) create mode 100644 src/tests/test_manager_search_display.py diff --git a/src/main.py b/src/main.py index 0b56a64f..5915c451 100644 --- a/src/main.py +++ b/src/main.py @@ -234,19 +234,40 @@ def handle_display_stats(password_manager: PasswordManager) -> None: print(colored(f"Error: Failed to display stats: {e}", "red")) -def print_matches(matches: list[tuple[int, str, str | None, str | None, bool]]) -> None: +def print_matches( + password_manager: PasswordManager, + matches: list[tuple[int, str, str | None, str | None, bool]], +) -> None: """Print a list of search matches.""" print(colored("\n[+] Matches:\n", "green")) for entry in matches: idx, website, username, url, blacklisted = entry + data = password_manager.entry_manager.retrieve_entry(idx) + etype = ( + data.get("type", data.get("kind", EntryType.PASSWORD.value)) + if data + else EntryType.PASSWORD.value + ) print(colored(f"Index: {idx}", "cyan")) - if website: - print(colored(f" Website: {website}", "cyan")) - if username: - print(colored(f" Username: {username}", "cyan")) - if url: - print(colored(f" URL: {url}", "cyan")) - print(colored(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "cyan")) + if etype == EntryType.TOTP.value: + print(colored(f" Label: {data.get('label', website)}", "cyan")) + print(colored(f" Derivation Index: {data.get('index', idx)}", "cyan")) + elif etype == EntryType.SEED.value: + print(colored(" Type: Seed Phrase", "cyan")) + elif etype == EntryType.SSH.value: + print(colored(" Type: SSH Key", "cyan")) + elif etype == EntryType.PGP.value: + print(colored(" Type: PGP Key", "cyan")) + elif etype == EntryType.NOSTR.value: + print(colored(" Type: Nostr Key", "cyan")) + else: + if website: + print(colored(f" Website: {website}", "cyan")) + if username: + print(colored(f" Username: {username}", "cyan")) + if url: + print(colored(f" URL: {url}", "cyan")) + print(colored(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "cyan")) print("-" * 40) @@ -831,7 +852,7 @@ def main(argv: list[str] | None = None) -> int: if args.command == "search": matches = password_manager.entry_manager.search_entries(args.query) if matches: - print_matches(matches) + print_matches(password_manager, matches) else: print(colored("No matching entries found.", "yellow")) return 0 @@ -841,7 +862,7 @@ def main(argv: list[str] | None = None) -> int: if not matches: print(colored("No matching entries found.", "yellow")) else: - print_matches(matches) + print_matches(password_manager, matches) return 1 idx = matches[0][0] entry = password_manager.entry_manager.retrieve_entry(idx) @@ -858,7 +879,7 @@ def main(argv: list[str] | None = None) -> int: if not matches: print(colored("No matching entries found.", "yellow")) else: - print_matches(matches) + print_matches(password_manager, matches) return 1 idx = matches[0][0] entry = password_manager.entry_manager.retrieve_entry(idx) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 522f3663..16476931 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1694,15 +1694,88 @@ class PasswordManager: return print(colored("\n[+] Search Results:\n", "green")) - for entry in results: - index, website, username, url, blacklisted = entry + 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")) - 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") - ) + 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) except Exception as e: diff --git a/src/tests/test_manager_search_display.py b/src/tests/test_manager_search_display.py new file mode 100644 index 00000000..2c460098 --- /dev/null +++ b/src/tests/test_manager_search_display.py @@ -0,0 +1,41 @@ +import sys +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace + +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 + + +def test_search_entries_shows_totp_details(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 + pm.secret_mode_enabled = False + + entry_mgr.add_totp("Example", TEST_SEED) + + monkeypatch.setattr("builtins.input", lambda *a, **k: "Example") + + pm.handle_search_entries() + out = capsys.readouterr().out + assert "Label: Example" in out + assert "Derivation Index" in out From 827851ea6343afad2f68643f47aedafd5080e2b8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 5 Jul 2025 08:59:29 -0400 Subject: [PATCH 3/9] Add warning prompts before showing sensitive keys --- src/password_manager/manager.py | 83 +++++++++++++++++++++++++++------ 1 file changed, 70 insertions(+), 13 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 16476931..6e16c7f8 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1047,7 +1047,16 @@ class PasswordManager: ) self.is_dirty = True self.last_update = time.time() + + if not confirm_action( + "WARNING: Displaying SSH keys reveals sensitive information. Continue? (Y/N): " + ): + print(colored("SSH key display cancelled.", "yellow")) + return + print(colored(f"\n[+] SSH key entry added with ID {index}.\n", "green")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) print(colored("Public Key:", "cyan")) print(pub_pem) print(colored("Private Key:", "cyan")) @@ -1078,9 +1087,22 @@ class PasswordManager: phrase = self.entry_manager.get_seed_phrase(index, self.parent_seed) self.is_dirty = True self.last_update = time.time() + + if not confirm_action( + "WARNING: Displaying the seed phrase reveals sensitive information. Continue? (Y/N): " + ): + print(colored("Seed phrase display cancelled.", "yellow")) + return + print(colored(f"\n[+] Seed entry added with ID {index}.\n", "green")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) print(colored("Seed Phrase:", "cyan")) print(colored(phrase, "yellow")) + if confirm_action("Show QR? (Y/N): "): + from password_manager.seedqr import encode_seedqr + + TotpManager.print_qr_code(encode_seedqr(phrase)) try: self.sync_vault() except Exception as nostr_error: @@ -1112,7 +1134,18 @@ class PasswordManager: ) self.is_dirty = True self.last_update = time.time() + + if not confirm_action( + "WARNING: Displaying the PGP key reveals sensitive information. Continue? (Y/N): " + ): + print(colored("PGP key display cancelled.", "yellow")) + return + print(colored(f"\n[+] PGP key entry added with ID {index}.\n", "green")) + if user_id: + print(colored(f"User ID: {user_id}", "cyan")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) print(colored(f"Fingerprint: {fingerprint}", "cyan")) print(priv_key) try: @@ -1238,10 +1271,23 @@ class PasswordManager: 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): " + ): + print(colored("SSH key display cancelled.", "yellow")) + 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")) + print(colored("Public Key:", "cyan")) + print(pub_pem) if self.secret_mode_enabled: copy_to_clipboard(priv_pem, self.clipboard_clear_delay) print( @@ -1250,24 +1296,28 @@ class PasswordManager: "green", ) ) - print(colored("Public Key:", "cyan")) - print(pub_pem) else: - print(colored("\n[+] Retrieved SSH Key Pair:\n", "green")) - print(colored("Public Key:", "cyan")) - print(pub_pem) print(colored("Private Key:", "cyan")) print(priv_pem) - if notes: - print(colored(f"Notes: {notes}", "cyan")) 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")) 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): " + ): + print(colored("Seed phrase display cancelled.", "yellow")) + return try: phrase = self.entry_manager.get_seed_phrase(index, self.parent_seed) + print(colored("\n[+] Retrieved Seed Phrase:\n", "green")) + if label: + print(colored(f"Label: {label}", "cyan")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) if self.secret_mode_enabled: copy_to_clipboard(phrase, self.clipboard_clear_delay) print( @@ -1277,9 +1327,8 @@ class PasswordManager: ) ) else: - print(colored("\n[+] Retrieved Seed Phrase:\n", "green")) print(colored(phrase, "yellow")) - if confirm_action("Show SeedQR? (Y/N): "): + if confirm_action("Show QR? (Y/N): "): from password_manager.seedqr import encode_seedqr TotpManager.print_qr_code(encode_seedqr(phrase)) @@ -1306,10 +1355,22 @@ class PasswordManager: 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): " + ): + print(colored("PGP key display cancelled.", "yellow")) + 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")) + print(colored(f"Fingerprint: {fingerprint}", "cyan")) if self.secret_mode_enabled: copy_to_clipboard(priv_key, self.clipboard_clear_delay) print( @@ -1319,11 +1380,7 @@ class PasswordManager: ) ) else: - print(colored("\n[+] Retrieved PGP Key:\n", "green")) - print(colored(f"Fingerprint: {fingerprint}", "cyan")) print(priv_key) - if notes: - print(colored(f"Notes: {notes}", "cyan")) 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")) 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 4/9] 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 5915c451..f17c94c6 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 48a60814..0f164249 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 6e16c7f8..4b20908d 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 5dd49c0cee2c3af3f2c322c263551349513caf16 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 5 Jul 2025 09:25:22 -0400 Subject: [PATCH 5/9] Clarify seed phrase QR prompt --- src/password_manager/manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 6e16c7f8..e511bb8c 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1099,7 +1099,7 @@ class PasswordManager: print(colored(f"Notes: {notes}", "cyan")) print(colored("Seed Phrase:", "cyan")) print(colored(phrase, "yellow")) - if confirm_action("Show QR? (Y/N): "): + if confirm_action("Show Compact Seed QR? (Y/N): "): from password_manager.seedqr import encode_seedqr TotpManager.print_qr_code(encode_seedqr(phrase)) @@ -1328,7 +1328,7 @@ class PasswordManager: ) else: print(colored(phrase, "yellow")) - if confirm_action("Show QR? (Y/N): "): + if confirm_action("Show Compact Seed QR? (Y/N): "): from password_manager.seedqr import encode_seedqr TotpManager.print_qr_code(encode_seedqr(phrase)) 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 6/9] 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 bdd9f28d..7c8466ee 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 f16a1472..7fa4b16c 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 c8a4ed76..46253f42 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 00000000..97279d63 --- /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 1412047b..c45ba9d3 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 65dc3860..7f1d70e4 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): From bacf32b46ed5d173fd0204bc8b529a2d64478fdd Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 5 Jul 2025 10:00:46 -0400 Subject: [PATCH 7/9] Remove deprecated website field --- README.md | 4 +- docs/advanced_cli.md | 6 +- docs/json_entries.md | 2 +- src/main.py | 2 +- src/password_manager/entry_management.py | 94 ++++++++++++++---------- src/password_manager/manager.py | 33 +++++++-- src/password_manager/migrations.py | 8 ++ src/tests/test_backup_restore.py | 4 +- src/tests/test_entry_add.py | 6 +- src/tests/test_index_import_export.py | 4 +- src/tests/test_migrations.py | 6 +- src/tests/test_nostr_index_size.py | 2 +- src/tests/test_pgp_entry.py | 4 +- src/tests/test_seed_entry.py | 4 +- src/tests/test_ssh_entry.py | 10 ++- src/tests/test_ssh_entry_valid.py | 2 +- 16 files changed, 123 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 40262715..3521d238 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ seedpass totp "email" # The code is printed and copied to your clipboard # Sort or filter the list view -seedpass list --sort website +seedpass list --sort label seedpass list --filter totp # Use the **Settings** menu to configure an extra backup directory @@ -185,7 +185,7 @@ The encrypted index file `seedpass_entries_db.json.enc` begins with `schema_vers "schema_version": 2, "entries": { "0": { - "website": "example.com", + "label": "example.com", "length": 8, "type": "password", "notes": "" diff --git a/docs/advanced_cli.md b/docs/advanced_cli.md index 4288b9b9..2f5aedab 100644 --- a/docs/advanced_cli.md +++ b/docs/advanced_cli.md @@ -53,7 +53,7 @@ The following table provides a quick reference to all available advanced CLI com | Retrieve a password entry | `retrieve` | `-R` | `--retrieve` | `seedpass retrieve --index 3` or `seedpass retrieve --title "GitHub"` | | Modify an existing entry | `modify` | `-M` | `--modify` | `seedpass modify --index 3 --title "GitHub Pro" --notes "Updated to pro account" --tags "work,development,pro" --length 22` | | Delete an entry | `delete` | `-D` | `--delete` | `seedpass delete --index 3` | -| List all entries | `list` | `-L` | `--list` | `seedpass list --sort website` | +| List all entries | `list` | `-L` | `--list` | `seedpass list --sort label` | | Search for a password entry | `search` | `-S` | `--search` | `seedpass search "GitHub"` | | Get password from query | `get` | | | `seedpass get "GitHub"` | Display a TOTP code | `totp` | | | `seedpass totp "email"` @@ -179,11 +179,11 @@ seedpass delete --index 3 **Long Flag:** `--list` **Description:** -Lists all password entries stored in the password manager. You can sort the output by index, website, or username and filter by entry type. +Lists all password entries stored in the password manager. You can sort the output by index, label, or username and filter by entry type. **Usage Example:** ```bash -seedpass list --sort website +seedpass list --sort label seedpass list --filter totp ``` diff --git a/docs/json_entries.md b/docs/json_entries.md index 9d09834e..1fd1e0b8 100644 --- a/docs/json_entries.md +++ b/docs/json_entries.md @@ -42,7 +42,7 @@ All entries belonging to a seed profile are summarized in an encrypted file name "schema_version": 2, "entries": { "0": { - "website": "example.com", + "label": "example.com", "length": 8, "type": "password", "notes": "" diff --git a/src/main.py b/src/main.py index f17c94c6..b7cc7fd0 100644 --- a/src/main.py +++ b/src/main.py @@ -262,7 +262,7 @@ def print_matches( print(colored(" Type: Nostr Key", "cyan")) else: if website: - print(colored(f" Website: {website}", "cyan")) + print(colored(f" Label: {website}", "cyan")) if username: print(colored(f" Username: {username}", "cyan")) if url: diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 0f164249..125fceca 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -65,6 +65,13 @@ class EntryManager: if "kind" not in entry: entry["kind"] = entry.get("type", EntryType.PASSWORD.value) entry.setdefault("type", entry["kind"]) + if "label" not in entry and "website" in entry: + entry["label"] = entry["website"] + if ( + "website" in entry + and entry.get("type") == EntryType.PASSWORD.value + ): + entry.pop("website", None) logger.debug("Index loaded successfully.") return data except Exception as e: @@ -106,7 +113,7 @@ class EntryManager: def add_entry( self, - website_name: str, + label: str, length: int, username: Optional[str] = None, url: Optional[str] = None, @@ -117,7 +124,7 @@ class EntryManager: """ Adds a new entry to the encrypted JSON index file. - :param website_name: The name of the website. + :param label: A label describing the entry (e.g. website name). :param length: The desired length of the password. :param username: (Optional) The username associated with the website. :param url: (Optional) The URL of the website. @@ -131,7 +138,7 @@ class EntryManager: data.setdefault("entries", {}) data["entries"][str(index)] = { - "website": website_name, + "label": label, "length": length, "username": username if username else "", "url": url if url else "", @@ -222,7 +229,11 @@ class EntryManager: raise def add_ssh_key( - self, parent_seed: str, index: int | None = None, notes: str = "" + self, + label: str, + parent_seed: str, + index: int | None = None, + notes: str = "", ) -> int: """Add a new SSH key pair entry. @@ -240,6 +251,7 @@ class EntryManager: "type": EntryType.SSH.value, "kind": EntryType.SSH.value, "index": index, + "label": label, "notes": notes, } self._save_index(data) @@ -263,6 +275,7 @@ class EntryManager: def add_pgp_key( self, + label: str, parent_seed: str, index: int | None = None, key_type: str = "ed25519", @@ -280,6 +293,7 @@ class EntryManager: "type": EntryType.PGP.value, "kind": EntryType.PGP.value, "index": index, + "label": label, "key_type": key_type, "user_id": user_id, "notes": notes, @@ -362,6 +376,7 @@ class EntryManager: def add_seed( self, + label: str, parent_seed: str, index: int | None = None, words_num: int = 24, @@ -378,6 +393,7 @@ class EntryManager: "type": EntryType.SEED.value, "kind": EntryType.SEED.value, "index": index, + "label": label, "words": words_num, "notes": notes, } @@ -596,11 +612,11 @@ class EntryManager: idx_str, entry = item if sort_by == "index": return int(idx_str) - if sort_by == "website": - return entry.get("website", "").lower() + if sort_by in {"website", "label"}: + return entry.get("label", entry.get("website", "")).lower() if sort_by == "username": return entry.get("username", "").lower() - raise ValueError("sort_by must be 'index', 'website', or 'username'") + raise ValueError("sort_by must be 'index', 'label', or 'username'") sorted_items = sorted(entries_data.items(), key=sort_key) @@ -616,19 +632,20 @@ class EntryManager: entries: List[Tuple[int, str, Optional[str], Optional[str], bool]] = [] for idx, entry in filtered_items: + label = entry.get("label", entry.get("website", "")) etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) - if etype == EntryType.TOTP.value: - entries.append((idx, entry.get("label", ""), None, None, False)) - else: + if etype == EntryType.PASSWORD.value: entries.append( ( idx, - entry.get("website", ""), + label, entry.get("username", ""), entry.get("url", ""), entry.get("blacklisted", False), ) ) + else: + entries.append((idx, label, None, None, False)) logger.debug(f"Total entries found: {len(entries)}") for idx, entry in filtered_items: @@ -644,8 +661,13 @@ class EntryManager: "cyan", ) ) - else: - print(colored(f" Website: {entry.get('website', '')}", "cyan")) + elif etype == EntryType.PASSWORD.value: + print( + colored( + f" Label: {entry.get('label', entry.get('website', ''))}", + "cyan", + ) + ) print( colored(f" Username: {entry.get('username') or 'N/A'}", "cyan") ) @@ -656,6 +678,13 @@ class EntryManager: "cyan", ) ) + else: + print(colored(f" Label: {entry.get('label', '')}", "cyan")) + print( + colored( + f" Derivation Index: {entry.get('index', index)}", "cyan" + ) + ) print("-" * 40) return entries @@ -680,16 +709,14 @@ class EntryManager: for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])): etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) - if etype == EntryType.TOTP.value: - label = entry.get("label", "") - notes = entry.get("notes", "") - if query_lower in label.lower() or query_lower in notes.lower(): - results.append((int(idx), label, None, None, False)) - else: - website = entry.get("website", "") + label = entry.get("label", entry.get("website", "")) + notes = entry.get("notes", "") + label_match = query_lower in label.lower() + notes_match = query_lower in notes.lower() + + if etype == EntryType.PASSWORD.value: username = entry.get("username", "") url = entry.get("url", "") - notes = entry.get("notes", "") custom_fields = entry.get("custom_fields", []) custom_match = any( query_lower in str(cf.get("label", "")).lower() @@ -697,21 +724,24 @@ class EntryManager: for cf in custom_fields ) if ( - query_lower in website.lower() + label_match or query_lower in username.lower() or query_lower in url.lower() - or query_lower in notes.lower() + or notes_match or custom_match ): results.append( ( int(idx), - website, + label, username, url, entry.get("blacklisted", False), ) ) + else: + if label_match or notes_match: + results.append((int(idx), label, None, None, False)) return results @@ -829,7 +859,7 @@ class EntryManager: for entry in entries: index, website, username, url, blacklisted = entry print(colored(f"Index: {index}", "cyan")) - print(colored(f" Website: {website}", "cyan")) + print(colored(f" Label: {website}", "cyan")) print(colored(f" Username: {username or 'N/A'}", "cyan")) print(colored(f" URL: {url or 'N/A'}", "cyan")) print( @@ -856,19 +886,9 @@ class EntryManager: 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" + label = entry.get("label", entry.get("website", "")) else: - label = etype + label = entry.get("label", etype) summaries.append((int(idx_str), label)) summaries.sort(key=lambda x: x[0]) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 21e62f23..427f80f8 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -859,9 +859,9 @@ class PasswordManager: def handle_add_password(self) -> None: try: - website_name = input("Enter the website name: ").strip() + website_name = input("Enter the label or website name: ").strip() if not website_name: - print(colored("Error: Website name cannot be empty.", "red")) + print(colored("Error: Label cannot be empty.", "red")) return username = input("Enter the username (optional): ").strip() @@ -1040,8 +1040,12 @@ class PasswordManager: def handle_add_ssh_key(self) -> None: """Add an SSH key pair entry and display the derived keys.""" try: + label = input("Label: ").strip() + if not label: + print(colored("Error: Label cannot be empty.", "red")) + return notes = input("Notes (optional): ").strip() - index = self.entry_manager.add_ssh_key(self.parent_seed, notes=notes) + index = self.entry_manager.add_ssh_key(label, self.parent_seed, notes=notes) priv_pem, pub_pem = self.entry_manager.get_ssh_key_pair( index, self.parent_seed ) @@ -1075,6 +1079,10 @@ class PasswordManager: def handle_add_seed(self) -> None: """Add a derived BIP-39 seed phrase entry.""" try: + label = input("Label: ").strip() + if not label: + print(colored("Error: Label cannot be empty.", "red")) + return words_input = input("Word count (12 or 24, default 24): ").strip() notes = input("Notes (optional): ").strip() if words_input and words_input not in {"12", "24"}: @@ -1082,7 +1090,7 @@ class PasswordManager: return words = int(words_input) if words_input else 24 index = self.entry_manager.add_seed( - self.parent_seed, words_num=words, notes=notes + label, self.parent_seed, words_num=words, notes=notes ) phrase = self.entry_manager.get_seed_phrase(index, self.parent_seed) self.is_dirty = True @@ -1117,6 +1125,10 @@ class PasswordManager: def handle_add_pgp(self) -> None: """Add a PGP key entry and display the generated key.""" try: + label = input("Label: ").strip() + if not label: + print(colored("Error: Label cannot be empty.", "red")) + return key_type = ( input("Key type (ed25519 or rsa, default ed25519): ").strip().lower() or "ed25519" @@ -1124,6 +1136,7 @@ class PasswordManager: user_id = input("User ID (optional): ").strip() notes = input("Notes (optional): ").strip() index = self.entry_manager.add_pgp_key( + label, self.parent_seed, key_type=key_type, user_id=user_id, @@ -1162,7 +1175,10 @@ class PasswordManager: def handle_add_nostr_key(self) -> None: """Add a Nostr key entry and display the derived keys.""" try: - label = input("Label (optional): ").strip() + label = input("Label: ").strip() + if not label: + print(colored("Error: Label cannot be empty.", "red")) + return notes = input("Notes (optional): ").strip() index = self.entry_manager.add_nostr_key(label, notes=notes) npub, nsec = self.entry_manager.get_nostr_key_pair(index, self.parent_seed) @@ -1779,6 +1795,7 @@ class PasswordManager: print(colored(f" Notes: {notes}", "cyan")) elif etype == EntryType.SEED.value: print(colored(" Type: Seed Phrase", "cyan")) + print(colored(f" Label: {entry.get('label', '')}", "cyan")) print(colored(f" Words: {entry.get('words', 24)}", "cyan")) print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan")) notes = entry.get("notes", "") @@ -1786,12 +1803,14 @@ class PasswordManager: print(colored(f" Notes: {notes}", "cyan")) elif etype == EntryType.SSH.value: print(colored(" Type: SSH 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")) elif etype == EntryType.PGP.value: print(colored(" Type: PGP Key", "cyan")) + print(colored(f" Label: {entry.get('label', '')}", "cyan")) print(colored(f" Key Type: {entry.get('key_type', 'ed25519')}", "cyan")) uid = entry.get("user_id", "") if uid: @@ -1808,11 +1827,11 @@ class PasswordManager: if notes: print(colored(f" Notes: {notes}", "cyan")) else: - website = entry.get("website", "") + website = entry.get("label", 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" Label: {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")) diff --git a/src/password_manager/migrations.py b/src/password_manager/migrations.py index dbcb16ae..6052e85f 100644 --- a/src/password_manager/migrations.py +++ b/src/password_manager/migrations.py @@ -33,6 +33,10 @@ def _v1_to_v2(data: dict) -> dict: for k, v in passwords.items(): v.setdefault("type", "password") v.setdefault("notes", "") + if "label" not in v and "website" in v: + v["label"] = v["website"] + if v.get("type") == "password" and "website" in v: + v.pop("website", None) entries[k] = v data["entries"] = entries data["schema_version"] = 2 @@ -46,6 +50,10 @@ def _v2_to_v3(data: dict) -> dict: for entry in entries.values(): entry.setdefault("custom_fields", []) entry.setdefault("origin", "") + if entry.get("type", "password") == "password": + if "label" not in entry and "website" in entry: + entry["label"] = entry["website"] + entry.pop("website", None) data["schema_version"] = 3 return data diff --git a/src/tests/test_backup_restore.py b/src/tests/test_backup_restore.py index 1c349e14..d37281e1 100644 --- a/src/tests/test_backup_restore.py +++ b/src/tests/test_backup_restore.py @@ -25,7 +25,7 @@ def test_backup_restore_workflow(monkeypatch): "schema_version": 3, "entries": { "0": { - "website": "a", + "label": "a", "length": 10, "type": "password", "kind": "password", @@ -48,7 +48,7 @@ def test_backup_restore_workflow(monkeypatch): "schema_version": 3, "entries": { "0": { - "website": "b", + "label": "b", "length": 12, "type": "password", "kind": "password", diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index d3231915..f44f3182 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -30,7 +30,7 @@ def test_add_and_retrieve_entry(): entry = entry_mgr.retrieve_entry(index) assert entry == { - "website": "example.com", + "label": "example.com", "length": 12, "username": "user", "url": "", @@ -69,9 +69,9 @@ def test_round_trip_entry_types(method, expected_type): index = 0 else: if method == "add_ssh_key": - index = entry_mgr.add_ssh_key(TEST_SEED) + index = entry_mgr.add_ssh_key("ssh", TEST_SEED) elif method == "add_seed": - index = entry_mgr.add_seed(TEST_SEED) + index = entry_mgr.add_seed("seed", TEST_SEED) else: index = getattr(entry_mgr, method)() diff --git a/src/tests/test_index_import_export.py b/src/tests/test_index_import_export.py index 5742cf3c..d6fa6227 100644 --- a/src/tests/test_index_import_export.py +++ b/src/tests/test_index_import_export.py @@ -34,7 +34,7 @@ def test_index_export_import_round_trip(): "schema_version": 3, "entries": { "0": { - "website": "example", + "label": "example", "type": "password", "notes": "", "custom_fields": [], @@ -52,7 +52,7 @@ def test_index_export_import_round_trip(): "schema_version": 3, "entries": { "0": { - "website": "changed", + "label": "changed", "type": "password", "notes": "", "custom_fields": [], diff --git a/src/tests/test_migrations.py b/src/tests/test_migrations.py index 87106cc9..4fb2f113 100644 --- a/src/tests/test_migrations.py +++ b/src/tests/test_migrations.py @@ -20,7 +20,7 @@ def test_migrate_v0_to_v3(tmp_path: Path): data = vault.load_index() assert data["schema_version"] == LATEST_VERSION expected_entry = { - "website": "a", + "label": "a", "length": 8, "type": "password", "notes": "", @@ -37,7 +37,7 @@ def test_migrate_v1_to_v3(tmp_path: Path): data = vault.load_index() assert data["schema_version"] == LATEST_VERSION expected_entry = { - "website": "b", + "label": "b", "length": 10, "type": "password", "notes": "", @@ -59,7 +59,7 @@ def test_migrate_v2_to_v3(tmp_path: Path): data = vault.load_index() assert data["schema_version"] == LATEST_VERSION expected_entry = { - "website": "c", + "label": "c", "length": 5, "type": "password", "notes": "", diff --git a/src/tests/test_nostr_index_size.py b/src/tests/test_nostr_index_size.py index ec7acb78..442330f9 100644 --- a/src/tests/test_nostr_index_size.py +++ b/src/tests/test_nostr_index_size.py @@ -58,7 +58,7 @@ def test_nostr_index_size_limits(pytestconfig: pytest.Config): if max_entries is not None and entry_count >= max_entries: break entry_mgr.add_entry( - website_name=f"site-{entry_count + 1}", + label=f"site-{entry_count + 1}", length=12, username="u" * size, url="https://example.com/" + "a" * size, diff --git a/src/tests/test_pgp_entry.py b/src/tests/test_pgp_entry.py index ee33b609..494d4d56 100644 --- a/src/tests/test_pgp_entry.py +++ b/src/tests/test_pgp_entry.py @@ -19,7 +19,9 @@ def test_pgp_key_determinism(): backup_mgr = BackupManager(tmp_path, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) - idx = entry_mgr.add_pgp_key(TEST_SEED, key_type="ed25519", user_id="Test") + idx = entry_mgr.add_pgp_key( + "pgp", TEST_SEED, key_type="ed25519", user_id="Test" + ) key1, fp1 = entry_mgr.get_pgp_key(idx, TEST_SEED) key2, fp2 = entry_mgr.get_pgp_key(idx, TEST_SEED) diff --git a/src/tests/test_seed_entry.py b/src/tests/test_seed_entry.py index 4d75c5b2..5b43eb4d 100644 --- a/src/tests/test_seed_entry.py +++ b/src/tests/test_seed_entry.py @@ -23,8 +23,8 @@ def test_seed_phrase_determinism(): backup_mgr = BackupManager(tmp_path, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) - idx_12 = entry_mgr.add_seed(TEST_SEED, words_num=12) - idx_24 = entry_mgr.add_seed(TEST_SEED, words_num=24) + idx_12 = entry_mgr.add_seed("seed12", TEST_SEED, words_num=12) + idx_24 = entry_mgr.add_seed("seed24", TEST_SEED, words_num=24) phrase12_a = entry_mgr.get_seed_phrase(idx_12, TEST_SEED) phrase12_b = entry_mgr.get_seed_phrase(idx_12, TEST_SEED) diff --git a/src/tests/test_ssh_entry.py b/src/tests/test_ssh_entry.py index 88736914..7a2f5524 100644 --- a/src/tests/test_ssh_entry.py +++ b/src/tests/test_ssh_entry.py @@ -20,9 +20,15 @@ def test_add_and_retrieve_ssh_key_pair(): backup_mgr = BackupManager(tmp_path, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) - index = entry_mgr.add_ssh_key(TEST_SEED) + index = entry_mgr.add_ssh_key("ssh", TEST_SEED) entry = entry_mgr.retrieve_entry(index) - assert entry == {"type": "ssh", "kind": "ssh", "index": index, "notes": ""} + assert entry == { + "type": "ssh", + "kind": "ssh", + "index": index, + "label": "ssh", + "notes": "", + } priv1, pub1 = entry_mgr.get_ssh_key_pair(index, TEST_SEED) priv2, pub2 = entry_mgr.get_ssh_key_pair(index, TEST_SEED) diff --git a/src/tests/test_ssh_entry_valid.py b/src/tests/test_ssh_entry_valid.py index 42372ee5..9b7ecad9 100644 --- a/src/tests/test_ssh_entry_valid.py +++ b/src/tests/test_ssh_entry_valid.py @@ -21,7 +21,7 @@ def test_ssh_private_key_corresponds_to_public(): backup_mgr = BackupManager(tmp_path, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) - idx = entry_mgr.add_ssh_key(TEST_SEED) + idx = entry_mgr.add_ssh_key("ssh", TEST_SEED) priv_pem, pub_pem = entry_mgr.get_ssh_key_pair(idx, TEST_SEED) priv_key = serialization.load_pem_private_key( From 9d6307781e7a8202f5dc78d517ad0802232dd06e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 5 Jul 2025 10:14:05 -0400 Subject: [PATCH 8/9] Show details after list selection --- src/password_manager/manager.py | 23 ++++++++++++++- src/tests/test_manager_list_entries.py | 41 ++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 427f80f8..8c84d282 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -19,6 +19,7 @@ from typing import Optional import shutil import time import select +import builtins from termcolor import colored from password_manager.encryption import EncryptionManager @@ -1213,6 +1214,26 @@ class PasswordManager: logging.error(f"Error during Nostr key setup: {e}", exc_info=True) print(colored(f"Error: Failed to add Nostr key: {e}", "red")) + 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.""" + + 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 + def handle_retrieve_entry(self) -> None: """ Handles retrieving a password from the index by prompting the user for the index number @@ -1885,7 +1906,7 @@ class PasswordManager: if not idx_input.isdigit(): print(colored("Invalid index.", "red")) continue - self.display_entry_details(int(idx_input)) + self.show_entry_details_by_index(int(idx_input)) return except Exception as e: logging.error(f"Failed to list entries: {e}", exc_info=True) diff --git a/src/tests/test_manager_list_entries.py b/src/tests/test_manager_list_entries.py index 97279d63..b7803604 100644 --- a/src/tests/test_manager_list_entries.py +++ b/src/tests/test_manager_list_entries.py @@ -41,3 +41,44 @@ def test_handle_list_entries(monkeypatch, capsys): out = capsys.readouterr().out assert "Example" in out assert "example.com" in out + + +def test_list_entries_show_details(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.parent_seed = TEST_SEED + pm.nostr_client = SimpleNamespace() + pm.fingerprint_dir = tmp_path + pm.secret_mode_enabled = False + + entry_mgr.add_totp("Example", TEST_SEED) + + 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 + ) + monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) + monkeypatch.setattr(sys.stdin, "readline", lambda *a, **k: "b\n") + monkeypatch.setattr( + "password_manager.manager.select.select", + lambda *a, **k: ([sys.stdin], [], []), + ) + + 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 From 1fb8958e34dd3b21a63a99789d4bf3b769081dfb Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 5 Jul 2025 10:28:38 -0400 Subject: [PATCH 9/9] Improve list entry navigation and display --- src/password_manager/entry_management.py | 8 +++---- src/password_manager/manager.py | 30 ++++++++++++++---------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 125fceca..1a657cc8 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -874,13 +874,13 @@ class EntryManager: def get_entry_summaries( self, filter_kind: str | None = None - ) -> list[tuple[int, str]]: - """Return a list of entry index and display labels.""" + ) -> list[tuple[int, str, str]]: + """Return a list of entry index, type, and display labels.""" try: data = self.vault.load_index() entries_data = data.get("entries", {}) - summaries: list[tuple[int, str]] = [] + summaries: list[tuple[int, str, 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: @@ -889,7 +889,7 @@ class EntryManager: label = entry.get("label", entry.get("website", "")) else: label = entry.get("label", etype) - summaries.append((int(idx_str), label)) + summaries.append((int(idx_str), etype, label)) summaries.sort(key=lambda x: x[0]) return summaries diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 8c84d282..af2bb9ed 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1895,19 +1895,23 @@ class PasswordManager: 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.show_entry_details_by_index(int(idx_input)) - return + while True: + print(colored("\n[+] Entries:\n", "green")) + for idx, etype, label in summaries: + if filter_kind is None: + display_type = etype.capitalize() + print(colored(f"{idx}. {display_type} - {label}", "cyan")) + else: + print(colored(f"{idx}. {label}", "cyan")) + idx_input = input( + "Enter index to view details or press Enter to go back: " + ).strip() + if not idx_input: + break + if not idx_input.isdigit(): + print(colored("Invalid index.", "red")) + continue + self.show_entry_details_by_index(int(idx_input)) 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"))