diff --git a/README.md b/README.md index 8e2eb8d..2355ea0 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,16 @@ seedpass export --file "~/seedpass_backup.json" # Later you can restore it seedpass import --file "~/seedpass_backup.json" +# Quickly find or retrieve entries +seedpass search "github" +seedpass get "github" +seedpass totp "email" +# The code is printed and copied to your clipboard + +# Sort or filter the list view +seedpass list --sort website +seedpass list --filter totp + # Use the **Settings** menu to configure an extra backup directory # on an external drive. ``` @@ -175,13 +185,14 @@ python src/main.py 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. Modify an Existing Entry + 5. 2FA Codes + 6. Settings + 7. Exit - Enter your choice (1-6): - ``` + Enter your choice (1-7): + ``` When choosing **Add Entry**, you can now select **Password** or **2FA (TOTP)**. @@ -234,7 +245,7 @@ wss://relay.primal.net You can manage your relays and sync with Nostr from the **Settings** menu: -1. From the main menu choose `4` (**Settings**). +1. From the main menu choose `6` (**Settings**). 2. Select `2` (**Nostr**) to open the Nostr submenu. 3. Choose `1` to back up your encrypted index to Nostr. 4. Select `2` to restore the index from Nostr. @@ -255,9 +266,10 @@ Back in the Settings menu you can: * Select `8` to export all 2FA codes. * Choose `9` to set an additional backup location. * Select `10` to change the inactivity timeout. -* Choose `11` to lock the vault and require re-entry of your password. -* Select `12` to return to the main menu. -* Choose `13` to view seed profile stats. +* Choose `11` to toggle Secret Mode and set the clipboard clear delay. +* Select `12` to lock the vault and require re-entry of your password. +* Choose `13` to return to the main menu. +* Select `14` to view seed profile stats. ## Running Tests @@ -277,7 +289,7 @@ when a new snapshot is triggered. Use the `NOSTR_TEST_DELAY` environment variable to control the delay between publishes when experimenting with large vaults. ```bash -NOSTR_TEST_DELAY=10 pytest -vv src/tests/test_nostr_index_size.py -m "desktop and network" +pytest -vv -s -n 0 src/tests/test_nostr_index_size.py --desktop --max-entries=1000 ``` ### Automatically Updating the Script Checksum @@ -429,6 +441,7 @@ The SeedPass roadmap outlines a structured development plan divided into distinc - **Toggle Setting:** Allow users to enable or disable "secret" mode. - **Clipboard Integration:** Ensure passwords are copied securely to the clipboard when "secret" mode is active. - **User Feedback:** Notify users that the password has been copied to the clipboard. + - **Settings Menu:** Toggle this mode under `Settings -> Toggle Secret Mode` and set how long the clipboard is retained. - **Two-Factor Security Model with Random Index Generation** - **Description:** Create a robust two-factor security system using a master seed and master password combination, enhanced with random index generation for additional security. - **Key Features:** diff --git a/docs/advanced_cli.md b/docs/advanced_cli.md index ea5dc15..4288b9b 100644 --- a/docs/advanced_cli.md +++ b/docs/advanced_cli.md @@ -16,22 +16,24 @@ The **Advanced CLI Commands** document provides an in-depth guide to the various - [4. Delete an Entry](#4-delete-an-entry) - [5. List All Entries](#5-list-all-entries) - [6. Search for a Password Entry](#6-search-for-a-password-entry) - - [7. Export Passwords to a File](#7-export-passwords-to-a-file) - - [8. Import Passwords from a File](#8-import-passwords-from-a-file) - - [9. Display Help Information](#9-display-help-information) - - [10. Display Application Version](#10-display-application-version) - - [11. Change Master Password](#11-change-master-password) - - [12. Enable Auto-Lock](#12-enable-auto-lock) - - [13. Disable Auto-Lock](#13-disable-auto-lock) - - [14. Generate a Strong Password](#14-generate-a-strong-password) - - [15. Verify Script Checksum](#15-verify-script-checksum) - - [16. Post Encrypted Snapshots to Nostr](#16-post-encrypted-snapshots-to-nostr) - - [17. Retrieve from Nostr](#17-retrieve-from-nostr) - - [18. Display Nostr Public Key](#18-display-nostr-public-key) - - [19. Set Custom Nostr Relays](#19-set-custom-nostr-relays) - - [20. Enable "Secret" Mode](#20-enable-secret-mode) - - [21. Batch Post Snapshot Deltas to Nostr](#21-batch-post-snapshot-deltas-to-nostr) - - [22. Show All Passwords](#22-show-all-passwords) + - [7. Get a Password by Query](#7-get-a-password-by-query) + - [8. Display a TOTP Code](#8-display-a-totp-code) + - [9. Export Passwords to a File](#9-export-passwords-to-a-file) + - [10. Import Passwords from a File](#8-import-passwords-from-a-file) + - [11. Display Help Information](#9-display-help-information) + - [12. Display Application Version](#10-display-application-version) + - [13. Change Master Password](#11-change-master-password) + - [14. Enable Auto-Lock](#12-enable-auto-lock) + - [15. Disable Auto-Lock](#13-disable-auto-lock) + - [16. Generate a Strong Password](#14-generate-a-strong-password) + - [17. Verify Script Checksum](#15-verify-script-checksum) + - [18. Post Encrypted Snapshots to Nostr](#16-post-encrypted-snapshots-to-nostr) + - [19. Retrieve from Nostr](#17-retrieve-from-nostr) + - [20. Display Nostr Public Key](#18-display-nostr-public-key) + - [21. Set Custom Nostr Relays](#19-set-custom-nostr-relays) + - [22. Enable "Secret" Mode](#20-enable-secret-mode) + - [23. Batch Post Snapshot Deltas to Nostr](#21-batch-post-snapshot-deltas-to-nostr) + - [24. Show All Passwords](#22-show-all-passwords) - [23. Add Notes to an Entry](#23-add-notes-to-an-entry) - [24. Add Tags to an Entry](#24-add-tags-to-an-entry) - [25. Search by Tag or Title](#25-search-by-tag-or-title) @@ -51,8 +53,11 @@ 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` | -| Search for a password entry | `search` | `-S` | `--search` | `seedpass search --query "GitHub"` | +| List all entries | `list` | `-L` | `--list` | `seedpass list --sort website` | +| 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"` +| | | | | `seedpass list --filter totp` | Export passwords to a file | `export` | `-E` | `--export` | `seedpass export --file "backup_passwords.json"` | | Import passwords from a file | `import` | `-I` | `--import` | `seedpass import --file "backup_passwords.json"` | | Display help information | `help` | `-H` | `--help` | `seedpass help` | @@ -174,11 +179,12 @@ seedpass delete --index 3 **Long Flag:** `--list` **Description:** -Lists all password entries stored in the password manager, displaying their indices, titles, and associated tags for easy reference. +Lists all password entries stored in the password manager. You can sort the output by index, website, or username and filter by entry type. **Usage Example:** ```bash -seedpass list +seedpass list --sort website +seedpass list --filter totp ``` --- @@ -194,11 +200,43 @@ Searches for password entries based on a query string, allowing users to find sp **Usage Example:** ```bash -seedpass search --query "GitHub" +seedpass search "GitHub" ``` **Options:** -- `--query` (`-Q`): The search string to look for in titles, tags, or notes. +- ``: The search string to look for in titles, usernames, URLs or notes. + +--- + +### 7. Get a Password by Query + +**Command:** `get` + +**Description:** +Searches for a password entry and immediately prints the generated password when exactly one match is found. + +**Usage Example:** +```bash +seedpass get "GitHub" +``` + +--- + +### 8. Display a TOTP Code + +**Command:** `totp` + +**Description:** +Looks up a TOTP entry by query and prints the current code. The code is also copied to your clipboard if possible. + +**Usage Example:** +```bash +seedpass totp "email" +``` + +--- + +### 9. Export Passwords to a File --- @@ -456,6 +494,8 @@ seedpass set-secret --disable - `--enable`: Activates "secret" mode. - `--disable`: Deactivates "secret" mode. +You can also enable or disable secret mode from the interactive Settings menu by selecting **Toggle Secret Mode**. + --- ### 21. Batch Post Snapshot Deltas to Nostr diff --git a/requirements.lock b/requirements.lock index c84c4b0..8463281 100644 --- a/requirements.lock +++ b/requirements.lock @@ -42,6 +42,7 @@ pycoin==0.92.20241201 pycparser==2.22 pycryptodome==3.23.0 pycryptodomex==3.23.0 +pyperclip==1.9.0 Pygments==2.19.2 PyNaCl==1.5.0 PySocks==1.7.1 diff --git a/src/main.py b/src/main.py index b6bdd48..f1dde23 100644 --- a/src/main.py +++ b/src/main.py @@ -16,10 +16,12 @@ import traceback from password_manager.manager import PasswordManager 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 from local_bip85.bip85 import Bip85Error +import pyperclip colorama_init() @@ -233,6 +235,22 @@ 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: + """Print a list of search matches.""" + print(colored("\n[+] Matches:\n", "green")) + for entry in matches: + idx, website, username, url, blacklisted = entry + 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")) + print("-" * 40) + + def handle_post_to_nostr( password_manager: PasswordManager, alt_summary: str | None = None ): @@ -481,6 +499,47 @@ def handle_set_additional_backup_location(pm: PasswordManager) -> None: print(colored(f"Error: {e}", "red")) +def handle_toggle_secret_mode(pm: PasswordManager) -> None: + """Toggle secret mode and adjust clipboard delay.""" + cfg = pm.config_manager + if cfg is None: + print(colored("Configuration manager unavailable.", "red")) + return + try: + enabled = cfg.get_secret_mode_enabled() + delay = cfg.get_clipboard_clear_delay() + except Exception as exc: + logging.error(f"Error loading secret mode settings: {exc}") + print(colored(f"Error loading settings: {exc}", "red")) + return + print(colored(f"Secret mode is currently {'ON' if enabled else 'OFF'}", "cyan")) + value = input("Enable secret mode? (y/n, blank to keep): ").strip().lower() + if value in ("y", "yes"): + enabled = True + elif value in ("n", "no"): + enabled = False + dur = input(f"Clipboard clear delay in seconds [{delay}]: ").strip() + if dur: + try: + delay = int(dur) + if delay <= 0: + print(colored("Delay must be positive.", "red")) + return + except ValueError: + print(colored("Invalid number.", "red")) + return + try: + cfg.set_secret_mode_enabled(enabled) + cfg.set_clipboard_clear_delay(delay) + pm.secret_mode_enabled = enabled + pm.clipboard_clear_delay = delay + status = "enabled" if enabled else "disabled" + print(colored(f"Secret mode {status}.", "green")) + except Exception as exc: + logging.error(f"Error saving secret mode: {exc}") + print(colored(f"Error: {exc}", "red")) + + def handle_profiles_menu(password_manager: PasswordManager) -> None: """Submenu for managing seed profiles.""" while True: @@ -566,8 +625,9 @@ def handle_settings(password_manager: PasswordManager) -> None: print("9. Set additional backup location") print("10. Set inactivity timeout") print("11. Lock Vault") - print("12. Back") - print("13. Stats") + print("12. Stats") + print("13. Toggle Secret Mode") + print("14. Back") choice = input("Select an option: ").strip() if choice == "1": handle_profiles_menu(password_manager) @@ -596,9 +656,11 @@ def handle_settings(password_manager: PasswordManager) -> None: print(colored("Vault locked. Please re-enter your password.", "yellow")) password_manager.unlock_vault() elif choice == "12": - break - elif choice == "13": handle_display_stats(password_manager) + elif choice == "13": + handle_toggle_secret_mode(password_manager) + elif choice == "14": + break else: print(colored("Invalid choice.", "red")) @@ -615,10 +677,11 @@ def display_menu( 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. Modify an Existing Entry + 5. 2FA Codes + 6. Settings + 7. Exit """ display_fn = getattr(password_manager, "display_stats", None) if callable(display_fn): @@ -643,7 +706,7 @@ def display_menu( print(colored(menu, "cyan")) try: choice = timed_input( - "Enter your choice (1-6): ", inactivity_timeout + "Enter your choice (1-7): ", inactivity_timeout ).strip() except TimeoutError: print(colored("Session timed out. Vault locked.", "yellow")) @@ -654,7 +717,7 @@ def display_menu( if not choice: print( colored( - "No input detected. Please enter a number between 1 and 6.", + "No input detected. Please enter a number between 1 and 7.", "yellow", ) ) @@ -682,14 +745,17 @@ def display_menu( password_manager.handle_retrieve_entry() elif choice == "3": password_manager.update_activity() - password_manager.handle_modify_entry() + password_manager.handle_search_entries() elif choice == "4": password_manager.update_activity() - password_manager.handle_display_totp_codes() + password_manager.handle_modify_entry() elif choice == "5": password_manager.update_activity() - handle_settings(password_manager) + password_manager.handle_display_totp_codes() elif choice == "6": + password_manager.update_activity() + handle_settings(password_manager) + elif choice == "7": logging.info("Exiting the program.") print(colored("Exiting the program.", "green")) password_manager.nostr_client.close_client_pool() @@ -698,15 +764,14 @@ def display_menu( print(colored("Invalid choice. Please select a valid option.", "red")) -if __name__ == "__main__": - # Configure logging with both file and console handlers +def main(argv: list[str] | None = None) -> int: + """Entry point for the SeedPass CLI.""" configure_logging() initialize_app() logger = logging.getLogger(__name__) logger.info("Starting SeedPass Password Manager") - # Load config from disk and parse command-line arguments - cfg = load_global_config() + load_global_config() parser = argparse.ArgumentParser() sub = parser.add_subparsers(dest="command") @@ -716,48 +781,97 @@ if __name__ == "__main__": imp = sub.add_parser("import") imp.add_argument("--file") - args = parser.parse_args() + search_p = sub.add_parser("search") + search_p.add_argument("query") + + get_p = sub.add_parser("get") + get_p.add_argument("query") + + totp_p = sub.add_parser("totp") + totp_p.add_argument("query") + + args = parser.parse_args(argv) - # Initialize PasswordManager and proceed with application logic try: password_manager = PasswordManager() logger.info("PasswordManager initialized successfully.") except (PasswordPromptError, Bip85Error) as e: logger.error(f"Failed to initialize PasswordManager: {e}", exc_info=True) print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red")) - sys.exit(1) + return 1 except Exception as e: logger.error(f"Failed to initialize PasswordManager: {e}", exc_info=True) print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red")) - sys.exit(1) + return 1 if args.command == "export": password_manager.handle_export_database(Path(args.file)) - sys.exit(0) - elif args.command == "import": + return 0 + if args.command == "import": password_manager.handle_import_database(Path(args.file)) - sys.exit(0) + return 0 + if args.command == "search": + matches = password_manager.entry_manager.search_entries(args.query) + if matches: + print_matches(matches) + else: + print(colored("No matching entries found.", "yellow")) + return 0 + if args.command == "get": + matches = password_manager.entry_manager.search_entries(args.query) + if len(matches) != 1: + if not matches: + print(colored("No matching entries found.", "yellow")) + else: + print_matches(matches) + return 1 + idx = matches[0][0] + entry = password_manager.entry_manager.retrieve_entry(idx) + if entry.get("type", EntryType.PASSWORD.value) != EntryType.PASSWORD.value: + print(colored("Entry is not a password entry.", "red")) + return 1 + length = int(entry.get("length", 0)) + pw = password_manager.password_generator.generate_password(length, idx) + print(pw) + return 0 + if args.command == "totp": + matches = password_manager.entry_manager.search_entries(args.query) + if len(matches) != 1: + if not matches: + print(colored("No matching entries found.", "yellow")) + else: + print_matches(matches) + return 1 + idx = matches[0][0] + entry = password_manager.entry_manager.retrieve_entry(idx) + if entry.get("type") != EntryType.TOTP.value: + print(colored("Entry is not a TOTP entry.", "red")) + return 1 + code = password_manager.entry_manager.get_totp_code( + idx, password_manager.parent_seed + ) + print(code) + try: + pyperclip.copy(code) + print(colored("Code copied to clipboard", "green")) + except Exception as exc: + logging.warning(f"Clipboard copy failed: {exc}") + return 0 - # Register signal handlers for graceful shutdown - def signal_handler(sig, frame): - """ - Handles termination signals to gracefully shutdown the NostrClient. - """ + def signal_handler(sig, _frame): print(colored("\nReceived shutdown signal. Exiting gracefully...", "yellow")) logging.info(f"Received shutdown signal: {sig}. Initiating graceful shutdown.") try: - password_manager.nostr_client.close_client_pool() # Gracefully close the ClientPool + password_manager.nostr_client.close_client_pool() logging.info("NostrClient closed successfully.") - except Exception as e: - logging.error(f"Error during shutdown: {e}") - print(colored(f"Error during shutdown: {e}", "red")) + except Exception as exc: + logging.error(f"Error during shutdown: {exc}") + print(colored(f"Error during shutdown: {exc}", "red")) sys.exit(0) - # Register the signal handlers - signal.signal(signal.SIGINT, signal_handler) # Handle Ctrl+C - signal.signal(signal.SIGTERM, signal_handler) # Handle termination signals + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) - # Display the interactive menu to the user try: display_menu( password_manager, inactivity_timeout=password_manager.inactivity_timeout @@ -766,29 +880,34 @@ if __name__ == "__main__": logger.info("Program terminated by user via KeyboardInterrupt.") print(colored("\nProgram terminated by user.", "yellow")) try: - password_manager.nostr_client.close_client_pool() # Gracefully close the ClientPool + password_manager.nostr_client.close_client_pool() logging.info("NostrClient closed successfully.") - except Exception as e: - logging.error(f"Error during shutdown: {e}") - print(colored(f"Error during shutdown: {e}", "red")) - sys.exit(0) + except Exception as exc: + logging.error(f"Error during shutdown: {exc}") + print(colored(f"Error during shutdown: {exc}", "red")) + return 0 except (PasswordPromptError, Bip85Error) as e: logger.error(f"A user-related error occurred: {e}", exc_info=True) print(colored(f"Error: {e}", "red")) try: password_manager.nostr_client.close_client_pool() logging.info("NostrClient closed successfully.") - except Exception as close_error: - logging.error(f"Error during shutdown: {close_error}") - print(colored(f"Error during shutdown: {close_error}", "red")) - sys.exit(1) + except Exception as exc: + logging.error(f"Error during shutdown: {exc}") + print(colored(f"Error during shutdown: {exc}", "red")) + return 1 except Exception as e: logger.error(f"An unexpected error occurred: {e}", exc_info=True) print(colored(f"Error: An unexpected error occurred: {e}", "red")) try: - password_manager.nostr_client.close_client_pool() # Attempt to close the ClientPool + password_manager.nostr_client.close_client_pool() logging.info("NostrClient closed successfully.") - except Exception as close_error: - logging.error(f"Error during shutdown: {close_error}") - print(colored(f"Error during shutdown: {close_error}", "red")) - sys.exit(1) + except Exception as exc: + logging.error(f"Error during shutdown: {exc}") + print(colored(f"Error during shutdown: {exc}", "red")) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/password_manager/config_manager.py b/src/password_manager/config_manager.py index 7c02aae..b1c8b8e 100644 --- a/src/password_manager/config_manager.py +++ b/src/password_manager/config_manager.py @@ -45,6 +45,8 @@ class ConfigManager: "password_hash": "", "inactivity_timeout": INACTIVITY_TIMEOUT, "additional_backup_path": "", + "secret_mode_enabled": False, + "clipboard_clear_delay": 45, } try: data = self.vault.load_config() @@ -56,6 +58,8 @@ class ConfigManager: data.setdefault("password_hash", "") data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT) data.setdefault("additional_backup_path", "") + data.setdefault("secret_mode_enabled", False) + data.setdefault("clipboard_clear_delay", 45) # Migrate legacy hashed_password.enc if present and password_hash is missing legacy_file = self.fingerprint_dir / "hashed_password.enc" @@ -144,3 +148,27 @@ class ConfigManager: config = self.load_config(require_pin=False) value = config.get("additional_backup_path", "") return value or None + + def set_secret_mode_enabled(self, enabled: bool) -> None: + """Persist the secret mode toggle.""" + config = self.load_config(require_pin=False) + config["secret_mode_enabled"] = bool(enabled) + self.save_config(config) + + def get_secret_mode_enabled(self) -> bool: + """Retrieve whether secret mode is enabled.""" + config = self.load_config(require_pin=False) + return bool(config.get("secret_mode_enabled", False)) + + def set_clipboard_clear_delay(self, delay: int) -> None: + """Persist clipboard clear timeout in seconds.""" + if delay <= 0: + raise ValueError("Delay must be positive") + config = self.load_config(require_pin=False) + config["clipboard_clear_delay"] = int(delay) + self.save_config(config) + + def get_clipboard_clear_delay(self) -> int: + """Retrieve clipboard clear delay in seconds.""" + config = self.load_config(require_pin=False) + return int(config.get("clipboard_clear_delay", 45)) diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index e526b44..098ede5 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -385,8 +385,10 @@ class EntryManager: colored(f"Error: Failed to modify entry at index {index}: {e}", "red") ) - def list_entries(self) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]: - """List all entries in the index.""" + def list_entries( + self, sort_by: str = "index", filter_kind: str | None = None + ) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]: + """List entries in the index with optional sorting and filtering.""" try: data = self.vault.load_index() entries_data = data.get("entries", {}) @@ -396,17 +398,36 @@ class EntryManager: print(colored("No entries found.", "yellow")) return [] - entries = [] - for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])): + def sort_key(item: Tuple[str, Dict[str, Any]]): + idx_str, entry = item + if sort_by == "index": + return int(idx_str) + if sort_by == "website": + return entry.get("website", "").lower() + if sort_by == "username": + return entry.get("username", "").lower() + raise ValueError("sort_by must be 'index', 'website', or 'username'") + + sorted_items = sorted(entries_data.items(), key=sort_key) + + filtered_items: List[Tuple[int, Dict[str, Any]]] = [] + for idx_str, entry in sorted_items: + if ( + filter_kind is not None + and entry.get("type", EntryType.PASSWORD.value) != filter_kind + ): + continue + filtered_items.append((int(idx_str), entry)) + + entries: List[Tuple[int, str, Optional[str], Optional[str], bool]] = [] + for idx, entry in filtered_items: etype = entry.get("type", EntryType.PASSWORD.value) if etype == EntryType.TOTP.value: - entries.append( - (int(idx), entry.get("label", ""), None, None, False) - ) + entries.append((idx, entry.get("label", ""), None, None, False)) else: entries.append( ( - int(idx), + idx, entry.get("website", ""), entry.get("username", ""), entry.get("url", ""), @@ -415,7 +436,7 @@ class EntryManager: ) logger.debug(f"Total entries found: {len(entries)}") - for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])): + for idx, entry in filtered_items: etype = entry.get("type", EntryType.PASSWORD.value) print(colored(f"Index: {idx}", "cyan")) if etype == EntryType.TOTP.value: @@ -449,6 +470,49 @@ class EntryManager: print(colored(f"Error: Failed to list entries: {e}", "red")) return [] + def search_entries( + self, query: str + ) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]: + """Return entries matching the query across common fields.""" + data = self.vault.load_index() + entries_data = data.get("entries", {}) + + if not entries_data: + return [] + + query_lower = query.lower() + results: List[Tuple[int, str, Optional[str], Optional[str], bool]] = [] + + for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])): + etype = entry.get("type", 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", "") + username = entry.get("username", "") + url = entry.get("url", "") + notes = entry.get("notes", "") + if ( + query_lower in website.lower() + or query_lower in username.lower() + or query_lower in url.lower() + or query_lower in notes.lower() + ): + results.append( + ( + int(idx), + website, + username, + url, + entry.get("blacklisted", False), + ) + ) + + return results + def delete_entry(self, index: int) -> None: """ Deletes an entry based on the provided index. @@ -549,12 +613,12 @@ class EntryManager: ) ) - def list_all_entries(self) -> None: - """ - Displays all entries in a formatted manner. - """ + def list_all_entries( + self, sort_by: str = "index", filter_kind: str | None = None + ) -> None: + """Display all entries using :meth:`list_entries`.""" try: - entries = self.list_entries() + entries = self.list_entries(sort_by=sort_by, filter_kind=filter_kind) if not entries: print(colored("No entries to display.", "yellow")) return diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 2d603f0..e6327d5 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -42,6 +42,7 @@ from utils.password_prompt import ( confirm_action, ) from utils.memory_protection import InMemorySecret +from utils.clipboard import copy_to_clipboard from constants import MIN_HEALTHY_RELAYS from constants import ( @@ -106,6 +107,8 @@ class PasswordManager: self.last_activity: float = time.time() self.locked: bool = False self.inactivity_timeout: float = INACTIVITY_TIMEOUT + self.secret_mode_enabled: bool = False + self.clipboard_clear_delay: int = 45 # Initialize the fingerprint manager first self.initialize_fingerprint_manager() @@ -776,6 +779,8 @@ class PasswordManager: self.inactivity_timeout = config.get( "inactivity_timeout", INACTIVITY_TIMEOUT ) + self.secret_mode_enabled = bool(config.get("secret_mode_enabled", False)) + self.clipboard_clear_delay = int(config.get("clipboard_clear_delay", 45)) self.nostr_client = NostrClient( encryption_manager=self.encryption_manager, @@ -1021,9 +1026,18 @@ class PasswordManager: try: while True: code = self.entry_manager.get_totp_code(index, self.parent_seed) - print(colored("\n[+] Retrieved 2FA Code:\n", "green")) - print(colored(f"Label: {label}", "cyan")) - print(colored(f"Code: {code}", "yellow")) + if self.secret_mode_enabled: + copy_to_clipboard(code, self.clipboard_clear_delay) + print( + colored( + f"[+] 2FA code for '{label}' copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored("\n[+] Retrieved 2FA Code:\n", "green")) + print(colored(f"Label: {label}", "cyan")) + print(colored(f"Code: {code}", "yellow")) if notes: print(colored(f"Notes: {notes}", "cyan")) remaining = self.entry_manager.get_totp_time_remaining(index) @@ -1084,18 +1098,30 @@ class PasswordManager: password = self.password_generator.generate_password(length, index) if password: - print( - colored(f"\n[+] Retrieved Password for {website_name}:\n", "green") - ) - print(colored(f"Password: {password}", "yellow")) - print(colored(f"Associated Username: {username or 'N/A'}", "cyan")) - print(colored(f"Associated URL: {url or 'N/A'}", "cyan")) - print( - colored( - f"Blacklist Status: {'Blacklisted' if blacklisted else 'Not Blacklisted'}", - "cyan", + if self.secret_mode_enabled: + copy_to_clipboard(password, self.clipboard_clear_delay) + print( + colored( + f"[+] Password for '{website_name}' copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print( + colored( + f"\n[+] Retrieved Password for {website_name}:\n", + "green", + ) + ) + print(colored(f"Password: {password}", "yellow")) + print(colored(f"Associated Username: {username or 'N/A'}", "cyan")) + print(colored(f"Associated URL: {url or 'N/A'}", "cyan")) + print( + colored( + f"Blacklist Status: {'Blacklisted' if blacklisted else 'Not Blacklisted'}", + "cyan", + ) ) - ) else: print(colored("Error: Failed to retrieve the password.", "red")) except Exception as e: @@ -1303,6 +1329,35 @@ class PasswordManager: logging.error(f"Error during modifying entry: {e}", exc_info=True) print(colored(f"Error: Failed to modify entry: {e}", "red")) + def handle_search_entries(self) -> None: + """Prompt for a query and display matching entries.""" + try: + query = input("Enter search string: ").strip() + if not query: + print(colored("No search string provided.", "yellow")) + return + + results = self.entry_manager.search_entries(query) + if not results: + print(colored("No matching entries found.", "yellow")) + return + + print(colored("\n[+] Search Results:\n", "green")) + for entry in results: + index, website, username, url, blacklisted = entry + 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") + ) + print("-" * 40) + + 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 delete_entry(self) -> None: """Deletes an entry from the password index.""" try: @@ -1373,7 +1428,13 @@ class PasswordManager: remaining = self.entry_manager.get_totp_time_remaining(idx) filled = int(20 * (period - remaining) / period) bar = "[" + "#" * filled + "-" * (20 - filled) + "]" - print(f"[{idx}] {label}: {code} {bar} {remaining:2d}s") + if self.secret_mode_enabled: + copy_to_clipboard(code, self.clipboard_clear_delay) + print( + f"[{idx}] {label}: [HIDDEN] {bar} {remaining:2d}s - copied to clipboard" + ) + else: + print(f"[{idx}] {label}: {code} {bar} {remaining:2d}s") if imported_list: print(colored("\nImported 2FA Codes:", "green")) for label, idx, period, _ in imported_list: @@ -1381,7 +1442,13 @@ class PasswordManager: remaining = self.entry_manager.get_totp_time_remaining(idx) filled = int(20 * (period - remaining) / period) bar = "[" + "#" * filled + "-" * (20 - filled) + "]" - print(f"[{idx}] {label}: {code} {bar} {remaining:2d}s") + if self.secret_mode_enabled: + copy_to_clipboard(code, self.clipboard_clear_delay) + print( + f"[{idx}] {label}: [HIDDEN] {bar} {remaining:2d}s - copied to clipboard" + ) + else: + print(f"[{idx}] {label}: {code} {bar} {remaining:2d}s") sys.stdout.flush() try: if sys.stdin in select.select([sys.stdin], [], [], 1)[0]: diff --git a/src/password_manager/portable_backup.py b/src/password_manager/portable_backup.py index d0effc4..14c7dce 100644 --- a/src/password_manager/portable_backup.py +++ b/src/password_manager/portable_backup.py @@ -82,6 +82,7 @@ def export_backup( json_bytes = json.dumps(wrapper, indent=2).encode("utf-8") dest_path.write_bytes(json_bytes) os.chmod(dest_path, 0o600) + backup_manager._create_additional_backup(dest_path) if publish: encrypted = vault.encryption_manager.encrypt_data(json_bytes) diff --git a/src/requirements.txt b/src/requirements.txt index 29ad75f..22a2dfd 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -21,3 +21,4 @@ mutmut==2.4.4 pyotp>=2.8.0 freezegun +pyperclip diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 80dbacd..4aa4e0d 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -20,6 +20,12 @@ def pytest_addoption(parser: pytest.Parser) -> None: default=False, help="run desktop-only tests", ) + parser.addoption( + "--max-entries", + type=int, + default=None, + help="maximum entries for nostr index size test", + ) def pytest_configure(config: pytest.Config) -> None: diff --git a/src/tests/test_auto_sync.py b/src/tests/test_auto_sync.py index e968c8d..bdd9f28 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 *_: "6") + monkeypatch.setattr(main, "timed_input", lambda *_: "7") 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 50760a2..df64494 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", "6"]) + inputs = iter(["", "abc", "7"]) 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", "6"]) + inputs = iter(["9", "7"]) 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", "4", "3", "6"]) + inputs = iter(["1", "4", "3", "7"]) 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 *_: "6") + monkeypatch.setattr(main, "timed_input", lambda *_: "7") 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_cli_subcommands.py b/src/tests/test_cli_subcommands.py new file mode 100644 index 0000000..2f5b7e3 --- /dev/null +++ b/src/tests/test_cli_subcommands.py @@ -0,0 +1,131 @@ +import sys +from types import SimpleNamespace +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +import main +from password_manager.entry_types import EntryType + + +def make_pm(search_results, entry=None, totp_code="123456"): + entry_mgr = SimpleNamespace( + search_entries=lambda q: search_results, + retrieve_entry=lambda idx: entry, + get_totp_code=lambda idx, seed: totp_code, + ) + pg = SimpleNamespace(generate_password=lambda l, i: "pw") + pm = SimpleNamespace( + entry_manager=entry_mgr, + password_generator=pg, + nostr_client=SimpleNamespace(close_client_pool=lambda: None), + parent_seed="seed", + inactivity_timeout=1, + ) + return pm + + +def test_search_command(monkeypatch, capsys): + pm = make_pm([(0, "Example", "user", "", False)]) + monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "configure_logging", lambda: None) + monkeypatch.setattr(main, "initialize_app", lambda: None) + monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) + rc = main.main(["search", "ex"]) + assert rc == 0 + out = capsys.readouterr().out + assert "Example" in out + + +def test_get_command(monkeypatch, capsys): + entry = {"type": EntryType.PASSWORD.value, "length": 8} + pm = make_pm([(0, "Example", "user", "", False)], entry=entry) + monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "configure_logging", lambda: None) + monkeypatch.setattr(main, "initialize_app", lambda: None) + monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) + rc = main.main(["get", "ex"]) + assert rc == 0 + out = capsys.readouterr().out + assert "pw" in out + + +def test_totp_command(monkeypatch, capsys): + entry = {"type": EntryType.TOTP.value, "period": 30, "index": 0} + pm = make_pm([(0, "Example", None, None, False)], entry=entry) + called = {} + monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "configure_logging", lambda: None) + monkeypatch.setattr(main, "initialize_app", lambda: None) + monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) + monkeypatch.setattr(main.pyperclip, "copy", lambda v: called.setdefault("val", v)) + rc = main.main(["totp", "ex"]) + assert rc == 0 + out = capsys.readouterr().out + assert "123456" in out + assert "copied to clipboard" in out.lower() + assert called.get("val") == "123456" + + +def test_search_command_no_results(monkeypatch, capsys): + pm = make_pm([]) + monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "configure_logging", lambda: None) + monkeypatch.setattr(main, "initialize_app", lambda: None) + monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) + rc = main.main(["search", "none"]) + assert rc == 0 + out = capsys.readouterr().out + assert "No matching entries found" in out + + +def test_get_command_multiple_matches(monkeypatch, capsys): + matches = [(0, "Example", "user", "", False), (1, "Ex2", "bob", "", False)] + pm = make_pm(matches) + monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "configure_logging", lambda: None) + monkeypatch.setattr(main, "initialize_app", lambda: None) + monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) + rc = main.main(["get", "ex"]) + assert rc == 1 + out = capsys.readouterr().out + assert "Matches" in out + + +def test_get_command_wrong_type(monkeypatch, capsys): + entry = {"type": EntryType.TOTP.value} + pm = make_pm([(0, "Example", "user", "", False)], entry=entry) + monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "configure_logging", lambda: None) + monkeypatch.setattr(main, "initialize_app", lambda: None) + monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) + rc = main.main(["get", "ex"]) + assert rc == 1 + out = capsys.readouterr().out + assert "Entry is not a password entry" in out + + +def test_totp_command_multiple_matches(monkeypatch, capsys): + matches = [(0, "GH", None, None, False), (1, "Git", None, None, False)] + pm = make_pm(matches) + monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "configure_logging", lambda: None) + monkeypatch.setattr(main, "initialize_app", lambda: None) + monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) + rc = main.main(["totp", "g"]) + assert rc == 1 + out = capsys.readouterr().out + assert "Matches" in out + + +def test_totp_command_wrong_type(monkeypatch, capsys): + entry = {"type": EntryType.PASSWORD.value, "length": 8} + pm = make_pm([(0, "Example", "user", "", False)], entry=entry) + monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "configure_logging", lambda: None) + monkeypatch.setattr(main, "initialize_app", lambda: None) + monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) + rc = main.main(["totp", "ex"]) + assert rc == 1 + out = capsys.readouterr().out + assert "Entry is not a TOTP entry" in out diff --git a/src/tests/test_clipboard_utils.py b/src/tests/test_clipboard_utils.py new file mode 100644 index 0000000..5aa5068 --- /dev/null +++ b/src/tests/test_clipboard_utils.py @@ -0,0 +1,68 @@ +from pathlib import Path +import pyperclip +import threading + +import sys + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from utils.clipboard import copy_to_clipboard + + +def test_copy_to_clipboard_clears(monkeypatch): + clipboard = {"text": ""} + + def fake_copy(val): + clipboard["text"] = val + + def fake_paste(): + return clipboard["text"] + + callbacks = {} + + class DummyTimer: + def __init__(self, delay, func): + callbacks["delay"] = delay + callbacks["func"] = func + + def start(self): + callbacks["started"] = True + + monkeypatch.setattr(pyperclip, "copy", fake_copy) + monkeypatch.setattr(pyperclip, "paste", fake_paste) + monkeypatch.setattr(threading, "Timer", DummyTimer) + + copy_to_clipboard("secret", 2) + assert clipboard["text"] == "secret" + assert callbacks["delay"] == 2 + assert callbacks["started"] + callbacks["func"]() + assert clipboard["text"] == "" + + +def test_copy_to_clipboard_does_not_clear_if_changed(monkeypatch): + clipboard = {"text": ""} + + def fake_copy(val): + clipboard["text"] = val + + def fake_paste(): + return clipboard["text"] + + callbacks = {} + + class DummyTimer: + def __init__(self, delay, func): + callbacks["func"] = func + + def start(self): + pass + + monkeypatch.setattr(pyperclip, "copy", fake_copy) + monkeypatch.setattr(pyperclip, "paste", fake_paste) + monkeypatch.setattr(threading, "Timer", DummyTimer) + + copy_to_clipboard("secret", 1) + fake_copy("other") + callbacks["func"]() + assert clipboard["text"] == "other" diff --git a/src/tests/test_config_manager.py b/src/tests/test_config_manager.py index 5be4603..b035a6b 100644 --- a/src/tests/test_config_manager.py +++ b/src/tests/test_config_manager.py @@ -130,3 +130,19 @@ def test_additional_backup_path_round_trip(): cfg_mgr.set_additional_backup_path(None) cfg2 = cfg_mgr.load_config(require_pin=False) assert cfg2["additional_backup_path"] == "" + + +def test_secret_mode_round_trip(): + with TemporaryDirectory() as tmpdir: + vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, Path(tmpdir)) + + cfg = cfg_mgr.load_config(require_pin=False) + assert cfg["secret_mode_enabled"] is False + assert cfg["clipboard_clear_delay"] == 45 + + cfg_mgr.set_secret_mode_enabled(True) + cfg_mgr.set_clipboard_clear_delay(99) + cfg2 = cfg_mgr.load_config(require_pin=False) + assert cfg2["secret_mode_enabled"] is True + assert cfg2["clipboard_clear_delay"] == 99 diff --git a/src/tests/test_inactivity_lock.py b/src/tests/test_inactivity_lock.py index 500bcd6..c8a4ed7 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 *_: "6") + monkeypatch.setattr(main, "timed_input", lambda *_: "7") 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(), "6"]) + responses = iter([TimeoutError(), "7"]) def fake_input(*_args, **_kwargs): val = next(responses) diff --git a/src/tests/test_list_entries_sort_filter.py b/src/tests/test_list_entries_sort_filter.py new file mode 100644 index 0000000..f56d3ef --- /dev/null +++ b/src/tests/test_list_entries_sort_filter.py @@ -0,0 +1,55 @@ +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.config_manager import ConfigManager +from password_manager.entry_types import EntryType + + +def setup_entry_manager(tmp_path: Path) -> EntryManager: + vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + return EntryManager(vault, backup_mgr) + + +def test_sort_by_website(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + em = setup_entry_manager(tmp_path) + idx0 = em.add_entry("b.com", 8, "user1") + idx1 = em.add_entry("A.com", 8, "user2") + result = em.list_entries(sort_by="website") + assert result == [ + (idx1, "A.com", "user2", "", False), + (idx0, "b.com", "user1", "", False), + ] + + +def test_sort_by_username(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + em = setup_entry_manager(tmp_path) + idx0 = em.add_entry("alpha.com", 8, "Charlie") + idx1 = em.add_entry("beta.com", 8, "alice") + result = em.list_entries(sort_by="username") + assert result == [ + (idx1, "beta.com", "alice", "", False), + (idx0, "alpha.com", "Charlie", "", False), + ] + + +def test_filter_by_type(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + em = setup_entry_manager(tmp_path) + em.add_entry("site", 8, "user") + em.add_totp("Example", TEST_SEED) + result = em.list_entries(filter_kind=EntryType.TOTP.value) + assert result == [(1, "Example", None, None, False)] diff --git a/src/tests/test_manager_display_totp_codes.py b/src/tests/test_manager_display_totp_codes.py index bff96e8..c9f12a7 100644 --- a/src/tests/test_manager_display_totp_codes.py +++ b/src/tests/test_manager_display_totp_codes.py @@ -39,6 +39,7 @@ def test_handle_display_totp_codes(monkeypatch, capsys): pm.nostr_client = FakeNostrClient() pm.fingerprint_dir = tmp_path pm.is_dirty = False + pm.secret_mode_enabled = False entry_mgr.add_totp("Example", TEST_SEED) @@ -78,6 +79,7 @@ def test_display_totp_codes_excludes_blacklisted(monkeypatch, capsys): pm.nostr_client = FakeNostrClient() pm.fingerprint_dir = tmp_path pm.is_dirty = False + pm.secret_mode_enabled = False entry_mgr.add_totp("Visible", TEST_SEED) entry_mgr.add_totp("Hidden", TEST_SEED) diff --git a/src/tests/test_manager_retrieve_totp.py b/src/tests/test_manager_retrieve_totp.py index 0ee97e8..e127773 100644 --- a/src/tests/test_manager_retrieve_totp.py +++ b/src/tests/test_manager_retrieve_totp.py @@ -39,6 +39,7 @@ def test_handle_retrieve_totp_entry(monkeypatch, capsys): pm.nostr_client = FakeNostrClient() pm.fingerprint_dir = tmp_path pm.is_dirty = False + pm.secret_mode_enabled = False entry_mgr.add_totp("Example", TEST_SEED) diff --git a/src/tests/test_menu_options.py b/src/tests/test_menu_options.py new file mode 100644 index 0000000..1412047 --- /dev/null +++ b/src/tests/test_menu_options.py @@ -0,0 +1,49 @@ +import time +from types import SimpleNamespace +from pathlib import Path +import sys +import pytest + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +import main + + +def _make_pm(calls): + return SimpleNamespace( + is_dirty=False, + last_update=time.time(), + last_activity=time.time(), + nostr_client=SimpleNamespace(close_client_pool=lambda: None), + handle_add_password=lambda: None, + handle_add_totp=lambda: None, + handle_retrieve_entry=lambda: None, + handle_search_entries=lambda: None, + handle_modify_entry=lambda: None, + handle_display_totp_codes=lambda: calls.append("totp"), + update_activity=lambda: None, + lock_vault=lambda: None, + unlock_vault=lambda: None, + ) + + +def test_menu_totp_option(monkeypatch): + calls = [] + pm = _make_pm(calls) + inputs = iter(["5", "7"]) + monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) + monkeypatch.setattr(main, "handle_settings", lambda *_: None) + with pytest.raises(SystemExit): + main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) + assert calls == ["totp"] + + +def test_menu_settings_option(monkeypatch): + calls = [] + pm = _make_pm(calls) + inputs = iter(["6", "7"]) + monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) + monkeypatch.setattr(main, "handle_settings", lambda *_: calls.append("settings")) + with pytest.raises(SystemExit): + main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) + assert calls == ["settings"] diff --git a/src/tests/test_menu_search.py b/src/tests/test_menu_search.py new file mode 100644 index 0000000..65dc386 --- /dev/null +++ b/src/tests/test_menu_search.py @@ -0,0 +1,38 @@ +import time +from types import SimpleNamespace +from pathlib import Path +import sys +import pytest + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +import main + + +def _make_pm(called): + pm = SimpleNamespace( + is_dirty=False, + last_update=time.time(), + last_activity=time.time(), + nostr_client=SimpleNamespace(close_client_pool=lambda: None), + handle_add_password=lambda: None, + handle_add_totp=lambda: None, + handle_retrieve_entry=lambda: None, + handle_search_entries=lambda: called.append("search"), + handle_modify_entry=lambda: None, + update_activity=lambda: None, + lock_vault=lambda: None, + unlock_vault=lambda: None, + ) + return pm + + +def test_menu_search_option(monkeypatch): + called = [] + pm = _make_pm(called) + inputs = iter(["3", "7"]) + monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) + monkeypatch.setattr("builtins.input", lambda *_: "query") + with pytest.raises(SystemExit): + main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) + assert called == ["search"] diff --git a/src/tests/test_nostr_index_size.py b/src/tests/test_nostr_index_size.py index cb93229..ec7acb7 100644 --- a/src/tests/test_nostr_index_size.py +++ b/src/tests/test_nostr_index_size.py @@ -24,7 +24,7 @@ from nostr.client import NostrClient, Kind, KindStandard @pytest.mark.desktop @pytest.mark.network -def test_nostr_index_size_limits(): +def test_nostr_index_size_limits(pytestconfig: pytest.Config): """Manually explore maximum index size for Nostr backups.""" seed = ( "abandon abandon abandon abandon abandon abandon abandon " @@ -47,13 +47,16 @@ def test_nostr_index_size_limits(): entry_mgr = EntryManager(vault, backup_mgr) delay = float(os.getenv("NOSTR_TEST_DELAY", "5")) + max_entries = pytestconfig.getoption("--max-entries") size = 16 batch = 100 entry_count = 0 max_payload = 60 * 1024 try: - while True: + while max_entries is None or entry_count < max_entries: for _ in range(batch): + if max_entries is not None and entry_count >= max_entries: + break entry_mgr.add_entry( website_name=f"site-{entry_count + 1}", length=12, @@ -85,8 +88,13 @@ def test_nostr_index_size_limits(): ) retrieved_ok = retrieved == encrypted results.append((entry_count, payload_size, True, retrieved_ok)) - if not retrieved_ok or payload_size > max_payload: - break + if max_entries is not None: + if entry_count >= max_entries: + break + else: + if not retrieved_ok or payload_size > max_payload: + break + size *= 2 except Exception: results.append((entry_count + 1, None, False, False)) @@ -98,7 +106,8 @@ def test_nostr_index_size_limits(): print(f"Nostr account npub: {npub}") print("Count | Payload Bytes | Published | Retrieved") for cnt, payload, pub, ret in results: - print(f"{cnt:>5} | {payload:>13} | {pub} | {ret}") + payload_str = str(payload) if payload is not None else "N/A" + print(f"{cnt:>5} | {payload_str:>13} | {pub} | {ret}") synced = sum(1 for _, _, pub, ret in results if pub and ret) print(f"Successfully synced entries: {synced}") diff --git a/src/tests/test_portable_backup.py b/src/tests/test_portable_backup.py index b22feb7..8c89fae 100644 --- a/src/tests/test_portable_backup.py +++ b/src/tests/test_portable_backup.py @@ -1,5 +1,6 @@ import json import base64 +import time from pathlib import Path from tempfile import TemporaryDirectory @@ -30,13 +31,13 @@ def setup_vault(tmp: Path): vault = Vault(enc_mgr, tmp) cfg = ConfigManager(vault, tmp) backup = BackupManager(tmp, cfg) - return vault, backup + return vault, backup, cfg def test_round_trip(monkeypatch): with TemporaryDirectory() as td: tmp = Path(td) - vault, backup = setup_vault(tmp) + vault, backup, _ = setup_vault(tmp) data = {"pw": 1} vault.save_index(data) @@ -54,7 +55,7 @@ from cryptography.fernet import InvalidToken def test_corruption_detection(monkeypatch): with TemporaryDirectory() as td: tmp = Path(td) - vault, backup = setup_vault(tmp) + vault, backup, _ = setup_vault(tmp) vault.save_index({"a": 1}) path = export_backup(vault, backup, parent_seed=SEED) @@ -72,7 +73,7 @@ def test_corruption_detection(monkeypatch): def test_import_over_existing(monkeypatch): with TemporaryDirectory() as td: tmp = Path(td) - vault, backup = setup_vault(tmp) + vault, backup, _ = setup_vault(tmp) vault.save_index({"v": 1}) path = export_backup(vault, backup, parent_seed=SEED) @@ -86,7 +87,7 @@ def test_import_over_existing(monkeypatch): def test_checksum_mismatch_detection(monkeypatch): with TemporaryDirectory() as td: tmp = Path(td) - vault, backup = setup_vault(tmp) + vault, backup, _ = setup_vault(tmp) vault.save_index({"a": 1}) path = export_backup(vault, backup, parent_seed=SEED) @@ -110,10 +111,38 @@ def test_export_import_seed_encrypted_with_different_key(monkeypatch): """Ensure backup round trip works when seed is encrypted with another key.""" with TemporaryDirectory() as td: tmp = Path(td) - vault, backup = setup_vault(tmp) + vault, backup, _ = setup_vault(tmp) vault.save_index({"v": 123}) path = export_backup(vault, backup, parent_seed=SEED) vault.save_index({"v": 0}) import_backup(vault, backup, path, parent_seed=SEED) assert vault.load_index()["v"] == 123 + + +def test_export_creates_additional_backup_and_import(monkeypatch): + with TemporaryDirectory() as td, TemporaryDirectory() as extra: + tmp = Path(td) + + seed_key = derive_key_from_password(PASSWORD) + seed_mgr = EncryptionManager(seed_key, tmp) + seed_mgr.encrypt_parent_seed(SEED) + + index_key = derive_index_key(SEED) + enc_mgr = EncryptionManager(index_key, tmp) + vault = Vault(enc_mgr, tmp) + cfg = ConfigManager(vault, tmp) + cfg.set_additional_backup_path(extra) + backup = BackupManager(tmp, cfg) + + vault.save_index({"v": 1}) + + monkeypatch.setattr(time, "time", lambda: 4444) + path = export_backup(vault, backup, parent_seed=SEED) + + extra_file = Path(extra) / f"{tmp.name}_{path.name}" + assert extra_file.exists() + + vault.save_index({"v": 0}) + import_backup(vault, backup, extra_file, parent_seed=SEED) + assert vault.load_index()["v"] == 1 diff --git a/src/tests/test_search_entries.py b/src/tests/test_search_entries.py new file mode 100644 index 0000000..b8e163f --- /dev/null +++ b/src/tests/test_search_entries.py @@ -0,0 +1,81 @@ +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.config_manager import ConfigManager + + +def setup_entry_manager(tmp_path: Path) -> EntryManager: + vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + return EntryManager(vault, backup_mgr) + + +def test_search_by_website(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + entry_mgr = setup_entry_manager(tmp_path) + + idx0 = entry_mgr.add_entry("Example.com", 12, "alice") + entry_mgr.add_entry("Other.com", 8, "bob") + + result = entry_mgr.search_entries("example") + assert result == [(idx0, "Example.com", "alice", "", False)] + + +def test_search_by_username(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + entry_mgr = setup_entry_manager(tmp_path) + + entry_mgr.add_entry("Example.com", 12, "alice") + idx1 = entry_mgr.add_entry("Test.com", 8, "Bob") + + result = entry_mgr.search_entries("bob") + assert result == [(idx1, "Test.com", "Bob", "", False)] + + +def test_search_by_url(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + entry_mgr = setup_entry_manager(tmp_path) + + idx = entry_mgr.add_entry("Example", 12, url="https://ex.com/login") + entry_mgr.add_entry("Other", 8) + + result = entry_mgr.search_entries("login") + assert result == [(idx, "Example", "", "https://ex.com/login", False)] + + +def test_search_by_notes_and_totp(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + entry_mgr = setup_entry_manager(tmp_path) + + idx_pw = entry_mgr.add_entry("Site", 8, notes="secret note") + entry_mgr.add_totp("GH", TEST_SEED) + idx_totp = entry_mgr.search_entries("GH")[0][0] + entry_mgr.modify_entry(idx_totp, notes="otp note") + + res_notes = entry_mgr.search_entries("secret") + assert res_notes == [(idx_pw, "Site", "", "", False)] + + res_totp = entry_mgr.search_entries("otp") + assert res_totp == [(idx_totp, "GH", None, None, False)] + + +def test_search_no_results(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + entry_mgr = setup_entry_manager(tmp_path) + + entry_mgr.add_entry("Example.com", 12, "alice") + result = entry_mgr.search_entries("missing") + assert result == [] diff --git a/src/tests/test_secret_mode.py b/src/tests/test_secret_mode.py new file mode 100644 index 0000000..f3a8b9a --- /dev/null +++ b/src/tests/test_secret_mode.py @@ -0,0 +1,82 @@ +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 setup_pm(tmp_path): + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw") + pm.parent_seed = TEST_SEED + pm.nostr_client = SimpleNamespace() + pm.fingerprint_dir = tmp_path + pm.config_manager = cfg_mgr + pm.secret_mode_enabled = True + pm.clipboard_clear_delay = 5 + return pm, entry_mgr + + +def test_password_retrieve_secret_mode(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + pm, entry_mgr = setup_pm(tmp) + entry_mgr.add_entry("example", 8) + + monkeypatch.setattr("builtins.input", lambda *a, **k: "0") + called = [] + monkeypatch.setattr( + "password_manager.manager.copy_to_clipboard", + lambda text, t: called.append((text, t)), + ) + + pm.handle_retrieve_entry() + out = capsys.readouterr().out + assert "Password:" not in out + assert "copied to clipboard" in out + assert called == [("pw", 5)] + + +def test_totp_display_secret_mode(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + pm, entry_mgr = setup_pm(tmp) + 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: 30 + ) + monkeypatch.setattr( + "password_manager.manager.select.select", + lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + called = [] + monkeypatch.setattr( + "password_manager.manager.copy_to_clipboard", + lambda text, t: called.append((text, t)), + ) + + pm.handle_display_totp_codes() + out = capsys.readouterr().out + assert "123456" not in out + assert "copied to clipboard" in out + assert called == [("123456", 5)] diff --git a/src/tests/test_settings_menu.py b/src/tests/test_settings_menu.py index 9c587dc..1ef8a25 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(["9", "12"]) + inputs = iter(["9", "14"]) 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/utils/__init__.py b/src/utils/__init__.py index 95a731d..2ff4329 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -25,6 +25,7 @@ try: from .password_prompt import prompt_for_password from .input_utils import timed_input from .memory_protection import InMemorySecret + from .clipboard import copy_to_clipboard if logger.isEnabledFor(logging.DEBUG): logger.info("Modules imported successfully.") @@ -49,4 +50,5 @@ __all__ = [ "prompt_for_password", "timed_input", "InMemorySecret", + "copy_to_clipboard", ] diff --git a/src/utils/clipboard.py b/src/utils/clipboard.py new file mode 100644 index 0000000..e576419 --- /dev/null +++ b/src/utils/clipboard.py @@ -0,0 +1,16 @@ +import threading +import pyperclip + + +def copy_to_clipboard(text: str, timeout: int) -> None: + """Copy text to the clipboard and clear after timeout seconds if unchanged.""" + + pyperclip.copy(text) + + def clear_clipboard() -> None: + if pyperclip.paste() == text: + pyperclip.copy("") + + timer = threading.Timer(timeout, clear_clipboard) + timer.daemon = True + timer.start()