diff --git a/README.md b/README.md index 6eecfaa..dc17765 100644 --- a/README.md +++ b/README.md @@ -220,18 +220,18 @@ python src/main.py Example menu: - ```bash - Select an option: - 1. Add Entry - 2. Retrieve Entry - 3. Search Entries - 4. Modify an Existing Entry - 5. 2FA Codes - 6. Settings - 7. Exit + ```bash + Select an option: + 1. Add Entry + 2. Retrieve Entry + 3. Search Entries + 4. List Entries + 5. Modify an Existing Entry + 6. 2FA Codes + 7. Settings - Enter your choice (1-7): - ``` + Enter your choice (1-7) or press Enter to exit: + ``` When choosing **Add Entry**, you can now select **Password**, **2FA (TOTP)**, **SSH Key**, **Seed Phrase**, or **PGP Key**. @@ -324,7 +324,8 @@ Back in the Settings menu you can: * Select `7` to export the database to an encrypted file. * Choose `8` to import a database from a backup file. * Select `9` to export all 2FA codes. -* Choose `10` to set an additional backup location. +* Choose `10` to set an additional backup location. A backup is created + immediately after the directory is configured. * Select `11` to change the inactivity timeout. * Choose `12` to lock the vault and require re-entry of your password. * Select `13` to view seed profile stats. The summary lists counts for diff --git a/landing/index.html b/landing/index.html index 60b4612..bb7a0fa 100644 --- a/landing/index.html +++ b/landing/index.html @@ -105,12 +105,13 @@ Select an option: 1. Add Entry 2. Retrieve Entry -3. Modify an Existing Entry -4. 2FA Codes -5. Settings -6. Exit +3. Search Entries +4. List Entries +5. Modify an Existing Entry +6. 2FA Codes +7. Settings -Enter your choice (1-6): +Enter your choice (1-7) or press Enter to exit:

Secret Mode

When Secret Mode is enabled, retrieved passwords are copied directly to your clipboard instead of displayed. The clipboard clears automatically after a delay you set.

diff --git a/src/main.py b/src/main.py index b7cc7fd..617550b 100644 --- a/src/main.py +++ b/src/main.py @@ -12,6 +12,7 @@ import gzip import tomli from colorama import init as colorama_init from termcolor import colored +from utils.color_scheme import color_text import traceback from password_manager.manager import PasswordManager @@ -19,7 +20,13 @@ from nostr.client import NostrClient from password_manager.entry_types import EntryType from constants import INACTIVITY_TIMEOUT, initialize_app from utils.password_prompt import PasswordPromptError -from utils import timed_input, copy_to_clipboard +from utils import ( + timed_input, + copy_to_clipboard, + clear_screen, + pause, + clear_and_print_fingerprint, +) from local_bip85.bip85 import Bip85Error @@ -201,6 +208,7 @@ def handle_list_fingerprints(password_manager: PasswordManager): print(colored("Available Seed Profiles:", "cyan")) for fp in fingerprints: print(colored(f"- {fp}", "cyan")) + pause() except Exception as e: logging.error(f"Error listing seed profiles: {e}", exc_info=True) print(colored(f"Error: Failed to list seed profiles: {e}", "red")) @@ -218,6 +226,7 @@ def handle_display_npub(password_manager: PasswordManager): else: print(colored("Nostr public key not available.", "red")) logging.error("Nostr public key not available.") + pause() except Exception as e: logging.error(f"Failed to display npub: {e}", exc_info=True) print(colored(f"Error: Failed to display npub: {e}", "red")) @@ -248,26 +257,28 @@ def print_matches( if data else EntryType.PASSWORD.value ) - print(colored(f"Index: {idx}", "cyan")) + print(color_text(f"Index: {idx}", "index")) if etype == EntryType.TOTP.value: - print(colored(f" Label: {data.get('label', website)}", "cyan")) - print(colored(f" Derivation Index: {data.get('index', idx)}", "cyan")) + print(color_text(f" Label: {data.get('label', website)}", "index")) + print(color_text(f" Derivation Index: {data.get('index', idx)}", "index")) elif etype == EntryType.SEED.value: - print(colored(" Type: Seed Phrase", "cyan")) + print(color_text(" Type: Seed Phrase", "index")) elif etype == EntryType.SSH.value: - print(colored(" Type: SSH Key", "cyan")) + print(color_text(" Type: SSH Key", "index")) elif etype == EntryType.PGP.value: - print(colored(" Type: PGP Key", "cyan")) + print(color_text(" Type: PGP Key", "index")) elif etype == EntryType.NOSTR.value: - print(colored(" Type: Nostr Key", "cyan")) + print(color_text(" Type: Nostr Key", "index")) else: if website: - print(colored(f" Label: {website}", "cyan")) + print(color_text(f" Label: {website}", "index")) if username: - print(colored(f" Username: {username}", "cyan")) + print(color_text(f" Username: {username}", "index")) if url: - print(colored(f" URL: {url}", "cyan")) - print(colored(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "cyan")) + print(color_text(f" URL: {url}", "index")) + print( + color_text(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "index") + ) print("-" * 40) @@ -338,6 +349,7 @@ def handle_view_relays(cfg_mgr: "ConfigManager") -> None: print(colored("\nCurrent Relays:", "cyan")) for idx, relay in enumerate(relays, start=1): print(colored(f"{idx}. {relay}", "cyan")) + pause() except Exception as e: logging.error(f"Error displaying relays: {e}") print(colored(f"Error: {e}", "red")) @@ -514,6 +526,8 @@ def handle_set_additional_backup_location(pm: PasswordManager) -> None: try: cfg_mgr.set_additional_backup_path(str(path)) print(colored(f"Additional backups will be copied to {path}", "green")) + if pm.backup_manager is not None: + pm.backup_manager.create_backup() except Exception as e: logging.error(f"Error saving backup path: {e}") print(colored(f"Error: {e}", "red")) @@ -563,13 +577,16 @@ def handle_toggle_secret_mode(pm: PasswordManager) -> None: def handle_profiles_menu(password_manager: PasswordManager) -> None: """Submenu for managing seed profiles.""" while True: - print("\nProfiles:") - print("1. Switch Seed Profile") - print("2. Add a New Seed Profile") - print("3. Remove an Existing Seed Profile") - print("4. List All Seed Profiles") - print("5. Back") - choice = input("Select an option: ").strip() + clear_and_print_fingerprint( + getattr(password_manager, "current_fingerprint", None), + "Main Menu > Settings > Profiles", + ) + print(color_text("\nProfiles:", "menu")) + print(color_text("1. Switch Seed Profile", "menu")) + print(color_text("2. Add a New Seed Profile", "menu")) + print(color_text("3. Remove an Existing Seed Profile", "menu")) + print(color_text("4. List All Seed Profiles", "menu")) + choice = input("Select an option or press Enter to go back: ").strip() password_manager.update_activity() if choice == "1": if not password_manager.handle_switch_fingerprint(): @@ -580,7 +597,7 @@ def handle_profiles_menu(password_manager: PasswordManager) -> None: handle_remove_fingerprint(password_manager) elif choice == "4": handle_list_fingerprints(password_manager) - elif choice == "5": + elif not choice: break else: print(colored("Invalid choice.", "red")) @@ -599,16 +616,19 @@ def handle_nostr_menu(password_manager: PasswordManager) -> None: return while True: - print("\nNostr Settings:") - print("1. Backup to Nostr") - print("2. Restore from Nostr") - print("3. View current relays") - print("4. Add a relay URL") - print("5. Remove a relay by number") - print("6. Reset to default relays") - print("7. Display Nostr Public Key") - print("8. Back") - choice = input("Select an option: ").strip() + clear_and_print_fingerprint( + getattr(password_manager, "current_fingerprint", None), + "Main Menu > Settings > Nostr", + ) + print(color_text("\nNostr Settings:", "menu")) + print(color_text("1. Backup to Nostr", "menu")) + print(color_text("2. Restore from Nostr", "menu")) + print(color_text("3. View current relays", "menu")) + print(color_text("4. Add a relay URL", "menu")) + print(color_text("5. Remove a relay by number", "menu")) + print(color_text("6. Reset to default relays", "menu")) + print(color_text("7. Display Nostr Public Key", "menu")) + choice = input("Select an option or press Enter to go back: ").strip() password_manager.update_activity() if choice == "1": handle_post_to_nostr(password_manager) @@ -624,7 +644,7 @@ def handle_nostr_menu(password_manager: PasswordManager) -> None: handle_reset_relays(password_manager) elif choice == "7": handle_display_npub(password_manager) - elif choice == "8": + elif not choice: break else: print(colored("Invalid choice.", "red")) @@ -633,56 +653,73 @@ def handle_nostr_menu(password_manager: PasswordManager) -> None: def handle_settings(password_manager: PasswordManager) -> None: """Interactive settings menu with submenus for profiles and Nostr.""" while True: - print("\nSettings:") - print("1. Profiles") - print("2. Nostr") - print("3. Change password") - print("4. Verify Script Checksum") - print("5. Generate Script Checksum") - print("6. Backup Parent Seed") - print("7. Export database") - print("8. Import database") - print("9. Export 2FA codes") - print("10. Set additional backup location") - print("11. Set inactivity timeout") - print("12. Lock Vault") - print("13. Stats") - print("14. Toggle Secret Mode") - print("15. Back") - choice = input("Select an option: ").strip() + clear_and_print_fingerprint( + getattr(password_manager, "current_fingerprint", None), + "Main Menu > Settings", + ) + print(color_text("\nSettings:", "menu")) + print(color_text("1. Profiles", "menu")) + print(color_text("2. Nostr", "menu")) + print(color_text("3. Change password", "menu")) + print(color_text("4. Verify Script Checksum", "menu")) + print(color_text("5. Generate Script Checksum", "menu")) + print(color_text("6. Backup Parent Seed", "menu")) + print(color_text("7. Export database", "menu")) + print(color_text("8. Import database", "menu")) + print(color_text("9. Export 2FA codes", "menu")) + print(color_text("10. Set additional backup location", "menu")) + print(color_text("11. Set inactivity timeout", "menu")) + print(color_text("12. Lock Vault", "menu")) + print(color_text("13. Stats", "menu")) + print(color_text("14. Toggle Secret Mode", "menu")) + choice = input("Select an option or press Enter to go back: ").strip() if choice == "1": handle_profiles_menu(password_manager) + pause() elif choice == "2": handle_nostr_menu(password_manager) + pause() elif choice == "3": password_manager.change_password() + pause() elif choice == "4": password_manager.handle_verify_checksum() + pause() elif choice == "5": password_manager.handle_update_script_checksum() + pause() elif choice == "6": password_manager.handle_backup_reveal_parent_seed() + pause() elif choice == "7": password_manager.handle_export_database() + pause() elif choice == "8": path = input("Enter path to backup file: ").strip() if path: password_manager.handle_import_database(Path(path)) + pause() elif choice == "9": password_manager.handle_export_totp_codes() + pause() elif choice == "10": handle_set_additional_backup_location(password_manager) + pause() elif choice == "11": handle_set_inactivity_timeout(password_manager) + pause() elif choice == "12": password_manager.lock_vault() print(colored("Vault locked. Please re-enter your password.", "yellow")) password_manager.unlock_vault() + pause() elif choice == "13": handle_display_stats(password_manager) + pause() elif choice == "14": handle_toggle_secret_mode(password_manager) - elif choice == "15": + pause() + elif not choice: break else: print(colored("Invalid choice.", "red")) @@ -705,12 +742,16 @@ def display_menu( 5. Modify an Existing Entry 6. 2FA Codes 7. Settings - 8. Exit """ display_fn = getattr(password_manager, "display_stats", None) if callable(display_fn): display_fn() + pause() while True: + clear_and_print_fingerprint( + getattr(password_manager, "current_fingerprint", None), + "Main Menu", + ) if time.time() - password_manager.last_activity > inactivity_timeout: print(colored("Session timed out. Vault locked.", "yellow")) password_manager.lock_vault() @@ -727,10 +768,11 @@ def display_menu( # Flush logging handlers for handler in logging.getLogger().handlers: handler.flush() - print(colored(menu, "cyan")) + print(color_text(menu, "menu")) try: choice = timed_input( - "Enter your choice (1-8): ", inactivity_timeout + "Enter your choice (1-7) or press Enter to exit: ", + inactivity_timeout, ).strip() except TimeoutError: print(colored("Session timed out. Vault locked.", "yellow")) @@ -739,24 +781,26 @@ def display_menu( continue password_manager.update_activity() if not choice: - print( - colored( - "No input detected. Please enter a number between 1 and 8.", - "yellow", - ) - ) - continue # Re-display the menu without marking as invalid + logging.info("Exiting the program.") + print(colored("Exiting the program.", "green")) + password_manager.nostr_client.close_client_pool() + sys.exit(0) if choice == "1": while True: - print("\nAdd Entry:") - print("1. Password") - print("2. 2FA (TOTP)") - print("3. SSH Key") - print("4. Seed Phrase") - print("5. Nostr Key Pair") - print("6. PGP Key") - print("7. Back") - sub_choice = input("Select entry type: ").strip() + clear_and_print_fingerprint( + getattr(password_manager, "current_fingerprint", None), + "Main Menu > Add Entry", + ) + print(color_text("\nAdd Entry:", "menu")) + print(color_text("1. Password", "menu")) + print(color_text("2. 2FA (TOTP)", "menu")) + print(color_text("3. SSH Key", "menu")) + print(color_text("4. Seed Phrase", "menu")) + print(color_text("5. Nostr Key Pair", "menu")) + print(color_text("6. PGP Key", "menu")) + sub_choice = input( + "Select entry type or press Enter to go back: " + ).strip() password_manager.update_activity() if sub_choice == "1": password_manager.handle_add_password() @@ -776,13 +820,17 @@ def display_menu( elif sub_choice == "6": password_manager.handle_add_pgp() break - elif sub_choice == "7": + elif not sub_choice: break else: print(colored("Invalid choice.", "red")) elif choice == "2": password_manager.update_activity() password_manager.handle_retrieve_entry() + clear_and_print_fingerprint( + getattr(password_manager, "current_fingerprint", None), + "Main Menu", + ) elif choice == "3": password_manager.update_activity() password_manager.handle_search_entries() @@ -798,11 +846,6 @@ def display_menu( 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() - sys.exit(0) else: print(colored("Invalid choice. Please select a valid option.", "red")) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index e969f63..536cde7 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -20,6 +20,7 @@ import shutil import time import builtins from termcolor import colored +from utils.color_scheme import color_text from utils.input_utils import timed_input from password_manager.encryption import EncryptionManager @@ -51,6 +52,11 @@ from utils.password_prompt import ( ) from utils.memory_protection import InMemorySecret from utils.clipboard import copy_to_clipboard +from utils.terminal_utils import ( + clear_screen, + pause, + clear_and_print_fingerprint, +) from constants import MIN_HEALTHY_RELAYS from constants import ( @@ -141,7 +147,7 @@ class PasswordManager: colored( "Warning: script checksum mismatch. " "Run 'Generate Script Checksum' in Settings if you've updated the app.", - "red", + "yellow", ) ) @@ -187,6 +193,7 @@ class PasswordManager: self.initialize_managers() self.locked = False self.update_activity() + self.sync_index_from_nostr() def initialize_fingerprint_manager(self): """ @@ -833,6 +840,29 @@ class PasswordManager: print(colored(f"Error: Failed to initialize managers: {e}", "red")) sys.exit(1) + def sync_index_from_nostr(self) -> None: + """Always fetch the latest vault data from Nostr and update the local index.""" + try: + result = asyncio.run(self.nostr_client.fetch_latest_snapshot()) + if not result: + return + manifest, chunks = result + encrypted = gzip.decompress(b"".join(chunks)) + if manifest.delta_since: + try: + version = int(manifest.delta_since) + deltas = asyncio.run(self.nostr_client.fetch_deltas_since(version)) + if deltas: + encrypted = deltas[-1] + except ValueError: + pass + current = self.vault.get_encrypted_index() + if current != encrypted: + self.vault.decrypt_and_save_index_from_nostr(encrypted) + logger.info("Local database synchronized from Nostr.") + except Exception as e: + logger.warning(f"Unable to sync index from Nostr: {e}") + def sync_index_from_nostr_if_missing(self) -> None: """Retrieve the password database from Nostr if it doesn't exist locally.""" index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc" @@ -860,6 +890,10 @@ class PasswordManager: def handle_add_password(self) -> None: try: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > Add Entry > Password", + ) website_name = input("Enter the label or website name: ").strip() if not website_name: print(colored("Error: Label cannot be empty.", "red")) @@ -936,20 +970,25 @@ class PasswordManager: f"Failed to post updated index to Nostr: {nostr_error}", exc_info=True, ) + pause() except Exception as e: logging.error(f"Error during password generation: {e}", exc_info=True) print(colored(f"Error: Failed to generate password: {e}", "red")) + pause() def handle_add_totp(self) -> None: """Add a TOTP entry either derived from the seed or imported.""" try: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > Add Entry > 2FA (TOTP)", + ) while True: print("\nAdd TOTP:") print("1. Make 2FA (derive from seed)") print("2. Import 2FA (paste otpauth URI or secret)") - print("3. Back") - choice = input("Select option: ").strip() + choice = input("Select option or press Enter to go back: ").strip() if choice == "1": label = input("Label: ").strip() if not label: @@ -982,7 +1021,7 @@ class PasswordManager: print(colored("Add this URI to your authenticator app:", "cyan")) print(colored(uri, "yellow")) TotpManager.print_qr_code(uri) - print(colored(f"Secret: {secret}\n", "cyan")) + print(color_text(f"Secret: {secret}\n", "deterministic")) try: self.sync_vault() except Exception as nostr_error: @@ -990,6 +1029,7 @@ class PasswordManager: f"Failed to post updated index to Nostr: {nostr_error}", exc_info=True, ) + pause() break elif choice == "2": raw = input("Paste otpauth URI or secret: ").strip() @@ -1027,20 +1067,26 @@ class PasswordManager: f"Failed to post updated index to Nostr: {nostr_error}", exc_info=True, ) + pause() break except ValueError as err: print(colored(f"Error: {err}", "red")) - elif choice == "3": + elif not choice: return else: print(colored("Invalid choice.", "red")) except Exception as e: logging.error(f"Error during TOTP setup: {e}", exc_info=True) print(colored(f"Error: Failed to add TOTP: {e}", "red")) + pause() def handle_add_ssh_key(self) -> None: """Add an SSH key pair entry and display the derived keys.""" try: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > Add Entry > SSH Key", + ) label = input("Label: ").strip() if not label: print(colored("Error: Label cannot be empty.", "red")) @@ -1063,9 +1109,9 @@ class PasswordManager: if notes: print(colored(f"Notes: {notes}", "cyan")) print(colored("Public Key:", "cyan")) - print(pub_pem) + print(color_text(pub_pem, "default")) print(colored("Private Key:", "cyan")) - print(priv_pem) + print(color_text(priv_pem, "deterministic")) try: self.sync_vault() except Exception as nostr_error: @@ -1073,13 +1119,19 @@ class PasswordManager: f"Failed to post updated index to Nostr: {nostr_error}", exc_info=True, ) + pause() except Exception as e: logging.error(f"Error during SSH key setup: {e}", exc_info=True) print(colored(f"Error: Failed to add SSH key: {e}", "red")) + pause() def handle_add_seed(self) -> None: """Add a derived BIP-39 seed phrase entry.""" try: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > Add Entry > Seed Phrase", + ) label = input("Label: ").strip() if not label: print(colored("Error: Label cannot be empty.", "red")) @@ -1103,11 +1155,18 @@ class PasswordManager: print(colored("Seed phrase display cancelled.", "yellow")) return - print(colored(f"\n[+] Seed entry added with ID {index}.\n", "green")) + print( + colored( + f"\n[+] Seed entry '{label}' added with ID {index}.\n", + "green", + ) + ) + print(colored(f"Index: {index}", "cyan")) + print(colored(f"Label: {label}", "cyan")) if notes: print(colored(f"Notes: {notes}", "cyan")) print(colored("Seed Phrase:", "cyan")) - print(colored(phrase, "yellow")) + print(color_text(phrase, "deterministic")) if confirm_action("Show Compact Seed QR? (Y/N): "): from password_manager.seedqr import encode_seedqr @@ -1119,13 +1178,19 @@ class PasswordManager: f"Failed to post updated index to Nostr: {nostr_error}", exc_info=True, ) + pause() except Exception as e: logging.error(f"Error during seed phrase setup: {e}", exc_info=True) print(colored(f"Error: Failed to add seed phrase: {e}", "red")) + pause() def handle_add_pgp(self) -> None: """Add a PGP key entry and display the generated key.""" try: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > Add Entry > PGP Key", + ) label = input("Label: ").strip() if not label: print(colored("Error: Label cannot be empty.", "red")) @@ -1161,7 +1226,7 @@ class PasswordManager: if notes: print(colored(f"Notes: {notes}", "cyan")) print(colored(f"Fingerprint: {fingerprint}", "cyan")) - print(priv_key) + print(color_text(priv_key, "deterministic")) try: self.sync_vault() except Exception as nostr_error: # pragma: no cover - best effort @@ -1169,13 +1234,19 @@ class PasswordManager: f"Failed to post updated index to Nostr: {nostr_error}", exc_info=True, ) + pause() except Exception as e: logging.error(f"Error during PGP key setup: {e}", exc_info=True) print(colored(f"Error: Failed to add PGP key: {e}", "red")) + pause() def handle_add_nostr_key(self) -> None: """Add a Nostr key entry and display the derived keys.""" try: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > Add Entry > Nostr Key Pair", + ) label = input("Label: ").strip() if not label: print(colored("Error: Label cannot be empty.", "red")) @@ -1196,7 +1267,7 @@ class PasswordManager: ) ) else: - print(colored(f"nsec: {nsec}", "cyan")) + print(color_text(f"nsec: {nsec}", "deterministic")) if confirm_action("Show QR code for npub? (Y/N): "): TotpManager.print_qr_code(f"nostr:{npub}") if confirm_action( @@ -1210,9 +1281,11 @@ class PasswordManager: f"Failed to post updated index to Nostr: {nostr_error}", exc_info=True, ) + pause() except Exception as e: logging.error(f"Error during Nostr key setup: {e}", exc_info=True) print(colored(f"Error: Failed to add Nostr key: {e}", "red")) + pause() def show_entry_details_by_index(self, index: int) -> None: """Display entry details using :meth:`handle_retrieve_entry` for the @@ -1240,16 +1313,22 @@ class PasswordManager: and displaying the corresponding password and associated details. """ try: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > Retrieve Entry", + ) index_input = input( "Enter the index number of the entry to retrieve: " ).strip() if not index_input.isdigit(): print(colored("Error: Index must be a number.", "red")) + pause() return index = int(index_input) entry = self.entry_manager.retrieve_entry(index) if not entry: + pause() return entry_type = entry.get("type", EntryType.PASSWORD.value) @@ -1259,7 +1338,7 @@ class PasswordManager: period = int(entry.get("period", 30)) notes = entry.get("notes", "") print(colored(f"Retrieving 2FA code for '{label}'.", "cyan")) - print(colored("Press 'b' then Enter to return to the menu.", "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) @@ -1274,7 +1353,9 @@ class PasswordManager: else: print(colored("\n[+] Retrieved 2FA Code:\n", "green")) print(colored(f"Label: {label}", "cyan")) - print(colored(f"Code: {code}", "yellow")) + 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")) remaining = self.entry_manager.get_totp_time_remaining(index) @@ -1286,7 +1367,10 @@ class PasswordManager: sys.stdout.flush() try: user_input = timed_input("", 1) - if user_input.strip().lower() == "b": + if ( + user_input.strip() == "" + or user_input.strip().lower() == "b" + ): exit_loop = True break except TimeoutError: @@ -1303,6 +1387,7 @@ class PasswordManager: 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")) + pause() return if entry_type == EntryType.SSH.value: notes = entry.get("notes", "") @@ -1322,7 +1407,7 @@ class PasswordManager: if notes: print(colored(f"Notes: {notes}", "cyan")) print(colored("Public Key:", "cyan")) - print(pub_pem) + print(color_text(pub_pem, "default")) if self.secret_mode_enabled: copy_to_clipboard(priv_pem, self.clipboard_clear_delay) print( @@ -1333,10 +1418,11 @@ class PasswordManager: ) else: print(colored("Private Key:", "cyan")) - print(priv_pem) + 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() return if entry_type == EntryType.SEED.value: notes = entry.get("notes", "") @@ -1349,6 +1435,7 @@ class PasswordManager: 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: @@ -1362,7 +1449,7 @@ class PasswordManager: ) ) else: - print(colored(phrase, "yellow")) + print(color_text(phrase, "deterministic")) if confirm_action("Show Compact Seed QR? (Y/N): "): from password_manager.seedqr import encode_seedqr @@ -1381,12 +1468,11 @@ class PasswordManager: app_no=39, words_len=words, ) - print(colored(f"Entropy: {entropy.hex()}", "cyan")) - if notes: - print(colored(f"Notes: {notes}", "cyan")) + 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() return if entry_type == EntryType.PGP.value: notes = entry.get("notes", "") @@ -1415,10 +1501,11 @@ class PasswordManager: ) ) else: - print(priv_key) + 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() return if entry_type == EntryType.NOSTR.value: label = entry.get("label", "") @@ -1439,7 +1526,7 @@ class PasswordManager: ) ) else: - print(colored(f"nsec: {nsec}", "cyan")) + print(color_text(f"nsec: {nsec}", "deterministic")) if confirm_action("Show QR code for npub? (Y/N): "): TotpManager.print_qr_code(f"nostr:{npub}") if confirm_action( @@ -1451,6 +1538,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() return website_name = entry.get("website") @@ -1474,7 +1562,7 @@ class PasswordManager: print( colored( f"Warning: This password is blacklisted and should not be used.", - "red", + "yellow", ) ) @@ -1496,7 +1584,7 @@ class PasswordManager: "green", ) ) - print(colored(f"Password: {password}", "yellow")) + 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( @@ -1537,9 +1625,11 @@ class PasswordManager: print(colored(f" {label}: {value}", "cyan")) else: print(colored("Error: Failed to retrieve the password.", "red")) + pause() 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")) + pause() def handle_modify_entry(self) -> None: """ @@ -1547,6 +1637,10 @@ class PasswordManager: and new details to update. """ try: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > Modify Entry", + ) index_input = input( "Enter the index number of the entry to modify: " ).strip() @@ -1782,24 +1876,52 @@ class PasswordManager: print(colored(f"Error: Failed to modify entry: {e}", "red")) def handle_search_entries(self) -> None: - """Prompt for a query and display matching entries.""" + """Prompt for a query, list matches and optionally show details.""" try: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > Search Entries", + ) query = input("Enter search string: ").strip() if not query: print(colored("No search string provided.", "yellow")) + pause() return results = self.entry_manager.search_entries(query) if not results: print(colored("No matching entries found.", "yellow")) + pause() return - print(colored("\n[+] Search Results:\n", "green")) - for match in results: - self.display_entry_details(match[0]) + while True: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > Search Entries", + ) + print(colored("\n[+] Search Results:\n", "green")) + for idx, label, username, _url, _b in results: + display_label = label + if username: + display_label += f" ({username})" + print(colored(f"{idx}. {display_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() or int(idx_input) not in [ + r[0] for r in results + ]: + print(colored("Invalid index.", "red")) + pause() + continue + self.show_entry_details_by_index(int(idx_input)) 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")) + pause() def display_entry_details(self, index: int) -> None: """Print detailed information for a single entry.""" @@ -1808,77 +1930,94 @@ class PasswordManager: return etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) - print(colored(f"Index: {index}", "cyan")) + print(color_text(f"Index: {index}", "index")) if etype == EntryType.TOTP.value: - print(colored(f" Label: {entry.get('label', '')}", "cyan")) - print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan")) + print(color_text(f" Label: {entry.get('label', '')}", "index")) print( - colored( + color_text(f" Derivation Index: {entry.get('index', index)}", "index") + ) + print( + color_text( f" Period: {entry.get('period', 30)}s Digits: {entry.get('digits', 6)}", - "cyan", + "index", ) ) notes = entry.get("notes", "") if notes: - print(colored(f" Notes: {notes}", "cyan")) + print(color_text(f" Notes: {notes}", "index")) 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")) + 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")) + print( + color_text(f" Derivation Index: {entry.get('index', index)}", "index") + ) notes = entry.get("notes", "") if notes: - print(colored(f" Notes: {notes}", "cyan")) + print(color_text(f" Notes: {notes}", "index")) 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")) + print(color_text(" Type: SSH Key", "index")) + print(color_text(f" Label: {entry.get('label', '')}", "index")) + print( + color_text(f" Derivation Index: {entry.get('index', index)}", "index") + ) notes = entry.get("notes", "") if notes: - print(colored(f" Notes: {notes}", "cyan")) + print(color_text(f" Notes: {notes}", "index")) 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")) + print(color_text(" Type: PGP Key", "index")) + print(color_text(f" Label: {entry.get('label', '')}", "index")) + print( + color_text(f" Key Type: {entry.get('key_type', 'ed25519')}", "index") + ) uid = entry.get("user_id", "") if uid: - print(colored(f" User ID: {uid}", "cyan")) - print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan")) + print(color_text(f" User ID: {uid}", "index")) + print( + color_text(f" Derivation Index: {entry.get('index', index)}", "index") + ) notes = entry.get("notes", "") if notes: - print(colored(f" Notes: {notes}", "cyan")) + print(color_text(f" Notes: {notes}", "index")) 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")) + print(color_text(" Type: Nostr Key", "index")) + print(color_text(f" Label: {entry.get('label', '')}", "index")) + print( + color_text(f" Derivation Index: {entry.get('index', index)}", "index") + ) notes = entry.get("notes", "") if notes: - print(colored(f" Notes: {notes}", "cyan")) + print(color_text(f" Notes: {notes}", "index")) else: website = entry.get("label", entry.get("website", "")) username = entry.get("username", "") url = entry.get("url", "") blacklisted = entry.get("blacklisted", False) - 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")) + print(color_text(f" Label: {website}", "index")) + print(color_text(f" Username: {username or 'N/A'}", "index")) + print(color_text(f" URL: {url or 'N/A'}", "index")) + print( + color_text(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "index") + ) 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() + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > List Entries", + ) + print(color_text("\nList Entries:", "menu")) + print(color_text("1. All", "menu")) + print(color_text("2. Passwords", "menu")) + print(color_text("3. 2FA (TOTP)", "menu")) + print(color_text("4. SSH Key", "menu")) + print(color_text("5. Seed Phrase", "menu")) + print(color_text("6. Nostr Key Pair", "menu")) + print(color_text("7. PGP", "menu")) + choice = input("Select entry type or press Enter to go back: ").strip() if choice == "1": filter_kind = None elif choice == "2": @@ -1893,7 +2032,7 @@ class PasswordManager: filter_kind = EntryType.NOSTR.value elif choice == "7": filter_kind = EntryType.PGP.value - elif choice == "8": + elif not choice: return else: print(colored("Invalid choice.", "red")) @@ -1903,6 +2042,10 @@ class PasswordManager: if not summaries: continue while True: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > List Entries", + ) print(colored("\n[+] Entries:\n", "green")) for idx, etype, label in summaries: if filter_kind is None: @@ -1963,6 +2106,10 @@ class PasswordManager: def handle_display_totp_codes(self) -> None: """Display all stored TOTP codes with a countdown progress bar.""" try: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > 2FA Codes", + ) data = self.entry_manager.vault.load_index() entries = data.get("entries", {}) totp_list: list[tuple[str, int, int, bool]] = [] @@ -1980,10 +2127,13 @@ class PasswordManager: return totp_list.sort(key=lambda t: t[0].lower()) - print(colored("Press 'b' then Enter to return to the menu.", "cyan")) + print(colored("Press Enter to return to the menu.", "cyan")) while True: - print("\033c", end="") - print(colored("Press 'b' then Enter to return to the menu.", "cyan")) + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > 2FA Codes", + ) + print(colored("Press Enter to return to the menu.", "cyan")) generated = [t for t in totp_list if not t[3]] imported_list = [t for t in totp_list if t[3]] if generated: @@ -1999,7 +2149,9 @@ class PasswordManager: f"[{idx}] {label}: [HIDDEN] {bar} {remaining:2d}s - copied to clipboard" ) else: - print(f"[{idx}] {label}: {code} {bar} {remaining:2d}s") + print( + f"[{idx}] {label}: {color_text(code, 'deterministic')} {bar} {remaining:2d}s" + ) if imported_list: print(colored("\nImported 2FA Codes:", "green")) for label, idx, period, _ in imported_list: @@ -2013,11 +2165,13 @@ class PasswordManager: f"[{idx}] {label}: [HIDDEN] {bar} {remaining:2d}s - copied to clipboard" ) else: - print(f"[{idx}] {label}: {code} {bar} {remaining:2d}s") + print( + f"[{idx}] {label}: {color_text(code, 'imported')} {bar} {remaining:2d}s" + ) sys.stdout.flush() try: user_input = timed_input("", 1) - if user_input.strip().lower() == "b": + if user_input.strip() == "" or user_input.strip().lower() == "b": break except TimeoutError: pass @@ -2033,6 +2187,10 @@ class PasswordManager: Handles verifying the script's checksum against the stored checksum to ensure integrity. """ try: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > Settings > Verify Script Checksum", + ) current_checksum = calculate_checksum(__file__) try: verified = verify_checksum(current_checksum, SCRIPT_CHECKSUM_FILE) @@ -2067,6 +2225,10 @@ class PasswordManager: print(colored("Operation cancelled.", "yellow")) return try: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > Settings > Generate Script Checksum", + ) script_path = Path(__file__).resolve() if update_checksum_file(str(script_path), str(SCRIPT_CHECKSUM_FILE)): print( @@ -2176,6 +2338,10 @@ class PasswordManager: ) -> Path | None: """Export the current database to an encrypted portable file.""" try: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > Settings > Export database", + ) path = export_backup( self.vault, self.backup_manager, @@ -2192,6 +2358,10 @@ class PasswordManager: def handle_import_database(self, src: Path) -> None: """Import a portable database file, replacing the current index.""" try: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > Settings > Import database", + ) import_backup( self.vault, self.backup_manager, @@ -2206,6 +2376,10 @@ class PasswordManager: def handle_export_totp_codes(self) -> Path | None: """Export all 2FA codes to a JSON file for other authenticator apps.""" try: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > Settings > Export 2FA codes", + ) data = self.entry_manager.vault.load_index() entries = data.get("entries", {}) @@ -2265,17 +2439,21 @@ class PasswordManager: Handles the backup and reveal of the parent seed. """ try: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > Settings > Backup Parent Seed", + ) print(colored("\n=== Backup Parent Seed ===", "yellow")) print( colored( "Warning: Revealing your parent seed is a highly sensitive operation.", - "red", + "yellow", ) ) print( colored( "Ensure you're in a secure, private environment and no one is watching your screen.", - "red", + "yellow", ) ) @@ -2296,7 +2474,7 @@ class PasswordManager: # Reveal the parent seed print(colored("\n=== Your BIP-85 Parent Seed ===", "green")) - print(colored(self.parent_seed, "yellow")) + print(color_text(self.parent_seed, "imported")) print( colored( "\nPlease write this down and store it securely. Do not share it with anyone.", @@ -2578,34 +2756,37 @@ class PasswordManager: print(colored("No statistics available.", "red")) return - print(colored("\n=== Seed Profile Stats ===", "yellow")) - print(colored(f"Total entries: {stats['total_entries']}", "cyan")) + print(color_text("\n=== Seed Profile Stats ===", "stats")) + print(color_text(f"Total entries: {stats['total_entries']}", "stats")) for etype, count in stats["entries"].items(): - print(colored(f" {etype}: {count}", "cyan")) - print(colored(f"Relays configured: {stats['relay_count']}", "cyan")) + print(color_text(f" {etype}: {count}", "stats")) + print(color_text(f"Relays configured: {stats['relay_count']}", "stats")) print( - colored( - f"Backups: {stats['backup_count']} (dir: {stats['backup_dir']})", "cyan" + color_text( + f"Backups: {stats['backup_count']} (dir: {stats['backup_dir']})", + "stats", ) ) if stats.get("additional_backup_path"): print( - colored(f"Additional backup: {stats['additional_backup_path']}", "cyan") + color_text( + f"Additional backup: {stats['additional_backup_path']}", "stats" + ) ) - print(colored(f"Schema version: {stats['schema_version']}", "cyan")) + print(color_text(f"Schema version: {stats['schema_version']}", "stats")) print( - colored( + color_text( f"Database checksum ok: {'yes' if stats['checksum_ok'] else 'no'}", - "cyan", + "stats", ) ) print( - colored( + color_text( f"Script checksum ok: {'yes' if stats['script_checksum_ok'] else 'no'}", - "cyan", + "stats", ) ) - print(colored(f"Snapshot chunks: {stats['chunk_count']}", "cyan")) - print(colored(f"Pending deltas: {stats['pending_deltas']}", "cyan")) + print(color_text(f"Snapshot chunks: {stats['chunk_count']}", "stats")) + print(color_text(f"Pending deltas: {stats['pending_deltas']}", "stats")) if stats.get("delta_since"): - print(colored(f"Latest delta id: {stats['delta_since']}", "cyan")) + print(color_text(f"Latest delta id: {stats['delta_since']}", "stats")) diff --git a/src/tests/test_auto_sync.py b/src/tests/test_auto_sync.py index 7c8466e..53cf949 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 *_: "8") + monkeypatch.setattr(main, "timed_input", lambda *_: "") 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 7fa4b16..6331c8c 100644 --- a/src/tests/test_cli_invalid_input.py +++ b/src/tests/test_cli_invalid_input.py @@ -52,12 +52,11 @@ 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", "8"]) + inputs = iter(["abc", ""]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) with pytest.raises(SystemExit): main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) out = capsys.readouterr().out - assert "No input detected" in out assert "Invalid choice. Please select a valid option." in out assert not any(called.values()) @@ -65,7 +64,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", "8"]) + inputs = iter(["9", ""]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) with pytest.raises(SystemExit): main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) @@ -77,7 +76,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", "8"]) + inputs = iter(["1", "8", "", ""]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) with pytest.raises(SystemExit): @@ -92,7 +91,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 *_: "8") + monkeypatch.setattr(main, "timed_input", lambda *_: "") 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 46253f4..0c68561 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 *_: "8") + monkeypatch.setattr(main, "timed_input", lambda *_: "") 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(), "8"]) + responses = iter([TimeoutError(), ""]) def fake_input(*_args, **_kwargs): val = next(responses) diff --git a/src/tests/test_manager_search_display.py b/src/tests/test_manager_search_display.py index 2c46009..16c83c2 100644 --- a/src/tests/test_manager_search_display.py +++ b/src/tests/test_manager_search_display.py @@ -13,7 +13,7 @@ from password_manager.manager import PasswordManager, EncryptionMode from password_manager.config_manager import ConfigManager -def test_search_entries_shows_totp_details(monkeypatch, capsys): +def test_search_entries_prompt_for_details(monkeypatch, capsys): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) @@ -27,15 +27,25 @@ def test_search_entries_shows_totp_details(monkeypatch, capsys): 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("builtins.input", lambda *a, **k: "Example") + 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("password_manager.manager.timed_input", lambda *a, **k: "b") + + inputs = iter(["Example", "0", ""]) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) pm.handle_search_entries() out = capsys.readouterr().out - assert "Label: Example" in out - assert "Derivation Index" in out + assert "0. Example" in out + assert "Retrieved 2FA Code" in out + assert "123456" in out diff --git a/src/tests/test_menu_options.py b/src/tests/test_menu_options.py index c45ba9d..ff8e7cf 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(["6", "8"]) + inputs = iter(["6", ""]) 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(["7", "8"]) + inputs = iter(["7", ""]) 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 7f1d70e..0e1d439 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", "8"]) + inputs = iter(["3", ""]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr("builtins.input", lambda *_: "query") with pytest.raises(SystemExit): diff --git a/src/tests/test_password_unlock_after_change.py b/src/tests/test_password_unlock_after_change.py index 22b8d6c..114b7f1 100644 --- a/src/tests/test_password_unlock_after_change.py +++ b/src/tests/test_password_unlock_after_change.py @@ -81,6 +81,7 @@ def test_password_change_and_unlock(monkeypatch): ) monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None) monkeypatch.setattr(PasswordManager, "initialize_managers", lambda self: None) + monkeypatch.setattr(PasswordManager, "sync_index_from_nostr", lambda self: None) pm.unlock_vault() diff --git a/src/tests/test_settings_menu.py b/src/tests/test_settings_menu.py index bd8a6f0..6899822 100644 --- a/src/tests/test_settings_menu.py +++ b/src/tests/test_settings_menu.py @@ -93,7 +93,7 @@ def test_settings_menu_additional_backup(monkeypatch): tmp_path = Path(tmpdir) pm, cfg_mgr, fp_mgr = setup_pm(tmp_path, monkeypatch) - inputs = iter(["10", "15"]) + inputs = iter(["10", ""]) with patch("main.handle_set_additional_backup_location") as handler: with patch("builtins.input", side_effect=lambda *_: next(inputs)): main.handle_settings(pm) diff --git a/src/tests/test_unlock_sync.py b/src/tests/test_unlock_sync.py new file mode 100644 index 0000000..7eef4c5 --- /dev/null +++ b/src/tests/test_unlock_sync.py @@ -0,0 +1,26 @@ +import time +from types import SimpleNamespace +from pathlib import Path +import sys + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.manager import PasswordManager + + +def test_unlock_triggers_sync(monkeypatch, tmp_path): + pm = PasswordManager.__new__(PasswordManager) + pm.fingerprint_dir = tmp_path + pm.setup_encryption_manager = lambda *a, **k: None + pm.initialize_bip85 = lambda: None + pm.initialize_managers = lambda: None + called = {"sync": False} + + def fake_sync(self): + called["sync"] = True + + monkeypatch.setattr(PasswordManager, "sync_index_from_nostr", fake_sync) + + pm.unlock_vault() + + assert called["sync"] diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 286d641..25a2ca0 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -28,6 +28,7 @@ try: from .input_utils import timed_input from .memory_protection import InMemorySecret from .clipboard import copy_to_clipboard + from .terminal_utils import clear_screen, pause, clear_and_print_fingerprint if logger.isEnabledFor(logging.DEBUG): logger.info("Modules imported successfully.") @@ -55,4 +56,7 @@ __all__ = [ "timed_input", "InMemorySecret", "copy_to_clipboard", + "clear_screen", + "clear_and_print_fingerprint", + "pause", ] diff --git a/src/utils/color_scheme.py b/src/utils/color_scheme.py new file mode 100644 index 0000000..db6798f --- /dev/null +++ b/src/utils/color_scheme.py @@ -0,0 +1,32 @@ +"""Utility functions for SeedPass CLI color scheme.""" + +from termcolor import colored + + +# ANSI escape for 256-color orange (color code 208) +_ORANGE = "\033[38;5;208m" +_RESET = "\033[0m" + + +def _apply_orange(text: str) -> str: + """Return text wrapped in ANSI codes for orange.""" + return f"{_ORANGE}{text}{_RESET}" + + +# Mapping of semantic color categories to actual colors +_COLOR_MAP = { + "deterministic": "red", + "imported": "orange", + "index": "yellow", + "menu": "cyan", + "stats": "green", + "default": "white", +} + + +def color_text(text: str, category: str = "default") -> str: + """Colorize ``text`` according to the given category.""" + color = _COLOR_MAP.get(category, "white") + if color == "orange": + return _apply_orange(text) + return colored(text, color) diff --git a/src/utils/terminal_utils.py b/src/utils/terminal_utils.py new file mode 100644 index 0000000..00f1975 --- /dev/null +++ b/src/utils/terminal_utils.py @@ -0,0 +1,33 @@ +"""Utility functions for terminal output.""" + +import sys + + +from termcolor import colored + + +def clear_screen() -> None: + """Clear the terminal screen using an ANSI escape code.""" + print("\033c", end="") + + +def clear_and_print_fingerprint( + fingerprint: str | None, breadcrumb: str | None = None +) -> None: + """Clear the screen and optionally display the current fingerprint and path.""" + clear_screen() + if fingerprint: + header = f"Seed Profile: {fingerprint}" + if breadcrumb: + header += f" > {breadcrumb}" + print(colored(header, "green")) + + +def pause(message: str = "Press Enter to continue...") -> None: + """Wait for the user to press Enter before proceeding.""" + if not sys.stdin or not sys.stdin.isatty(): + return + try: + input(message) + except EOFError: + pass