Merge pull request #225 from PR0M3TH3AN/beta

Beta
This commit is contained in:
thePR0M3TH3AN
2025-07-04 00:17:56 -04:00
committed by GitHub
28 changed files with 1052 additions and 133 deletions

View File

@@ -124,6 +124,16 @@ seedpass export --file "~/seedpass_backup.json"
# Later you can restore it # Later you can restore it
seedpass import --file "~/seedpass_backup.json" 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 # Use the **Settings** menu to configure an extra backup directory
# on an external drive. # on an external drive.
``` ```
@@ -175,13 +185,14 @@ python src/main.py
Select an option: Select an option:
1. Add Entry 1. Add Entry
2. Retrieve Entry 2. Retrieve Entry
3. Modify an Existing Entry 3. Search Entries
4. 2FA Codes 4. Modify an Existing Entry
5. Settings 5. 2FA Codes
6. Exit 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)**. 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: 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. 2. Select `2` (**Nostr**) to open the Nostr submenu.
3. Choose `1` to back up your encrypted index to Nostr. 3. Choose `1` to back up your encrypted index to Nostr.
4. Select `2` to restore the index from 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. * Select `8` to export all 2FA codes.
* Choose `9` to set an additional backup location. * Choose `9` to set an additional backup location.
* Select `10` to change the inactivity timeout. * Select `10` to change the inactivity timeout.
* Choose `11` to lock the vault and require re-entry of your password. * Choose `11` to toggle Secret Mode and set the clipboard clear delay.
* Select `12` to return to the main menu. * Select `12` to lock the vault and require re-entry of your password.
* Choose `13` to view seed profile stats. * Choose `13` to return to the main menu.
* Select `14` to view seed profile stats.
## Running Tests ## 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. variable to control the delay between publishes when experimenting with large vaults.
```bash ```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 ### 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. - **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. - **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. - **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** - **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. - **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:** - **Key Features:**

View File

@@ -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) - [4. Delete an Entry](#4-delete-an-entry)
- [5. List All Entries](#5-list-all-entries) - [5. List All Entries](#5-list-all-entries)
- [6. Search for a Password Entry](#6-search-for-a-password-entry) - [6. Search for a Password Entry](#6-search-for-a-password-entry)
- [7. Export Passwords to a File](#7-export-passwords-to-a-file) - [7. Get a Password by Query](#7-get-a-password-by-query)
- [8. Import Passwords from a File](#8-import-passwords-from-a-file) - [8. Display a TOTP Code](#8-display-a-totp-code)
- [9. Display Help Information](#9-display-help-information) - [9. Export Passwords to a File](#9-export-passwords-to-a-file)
- [10. Display Application Version](#10-display-application-version) - [10. Import Passwords from a File](#8-import-passwords-from-a-file)
- [11. Change Master Password](#11-change-master-password) - [11. Display Help Information](#9-display-help-information)
- [12. Enable Auto-Lock](#12-enable-auto-lock) - [12. Display Application Version](#10-display-application-version)
- [13. Disable Auto-Lock](#13-disable-auto-lock) - [13. Change Master Password](#11-change-master-password)
- [14. Generate a Strong Password](#14-generate-a-strong-password) - [14. Enable Auto-Lock](#12-enable-auto-lock)
- [15. Verify Script Checksum](#15-verify-script-checksum) - [15. Disable Auto-Lock](#13-disable-auto-lock)
- [16. Post Encrypted Snapshots to Nostr](#16-post-encrypted-snapshots-to-nostr) - [16. Generate a Strong Password](#14-generate-a-strong-password)
- [17. Retrieve from Nostr](#17-retrieve-from-nostr) - [17. Verify Script Checksum](#15-verify-script-checksum)
- [18. Display Nostr Public Key](#18-display-nostr-public-key) - [18. Post Encrypted Snapshots to Nostr](#16-post-encrypted-snapshots-to-nostr)
- [19. Set Custom Nostr Relays](#19-set-custom-nostr-relays) - [19. Retrieve from Nostr](#17-retrieve-from-nostr)
- [20. Enable "Secret" Mode](#20-enable-secret-mode) - [20. Display Nostr Public Key](#18-display-nostr-public-key)
- [21. Batch Post Snapshot Deltas to Nostr](#21-batch-post-snapshot-deltas-to-nostr) - [21. Set Custom Nostr Relays](#19-set-custom-nostr-relays)
- [22. Show All Passwords](#22-show-all-passwords) - [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) - [23. Add Notes to an Entry](#23-add-notes-to-an-entry)
- [24. Add Tags to an Entry](#24-add-tags-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) - [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"` | | 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` | | 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` | | Delete an entry | `delete` | `-D` | `--delete` | `seedpass delete --index 3` |
| List all entries | `list` | `-L` | `--list` | `seedpass list` | | List all entries | `list` | `-L` | `--list` | `seedpass list --sort website` |
| Search for a password entry | `search` | `-S` | `--search` | `seedpass search --query "GitHub"` | | 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"` | | 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"` | | Import passwords from a file | `import` | `-I` | `--import` | `seedpass import --file "backup_passwords.json"` |
| Display help information | `help` | `-H` | `--help` | `seedpass help` | | Display help information | `help` | `-H` | `--help` | `seedpass help` |
@@ -174,11 +179,12 @@ seedpass delete --index 3
**Long Flag:** `--list` **Long Flag:** `--list`
**Description:** **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:** **Usage Example:**
```bash ```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:** **Usage Example:**
```bash ```bash
seedpass search --query "GitHub" seedpass search "GitHub"
``` ```
**Options:** **Options:**
- `--query` (`-Q`): The search string to look for in titles, tags, or notes. - `<query>`: 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. - `--enable`: Activates "secret" mode.
- `--disable`: Deactivates "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 ### 21. Batch Post Snapshot Deltas to Nostr

View File

@@ -42,6 +42,7 @@ pycoin==0.92.20241201
pycparser==2.22 pycparser==2.22
pycryptodome==3.23.0 pycryptodome==3.23.0
pycryptodomex==3.23.0 pycryptodomex==3.23.0
pyperclip==1.9.0
Pygments==2.19.2 Pygments==2.19.2
PyNaCl==1.5.0 PyNaCl==1.5.0
PySocks==1.7.1 PySocks==1.7.1

View File

@@ -16,10 +16,12 @@ import traceback
from password_manager.manager import PasswordManager from password_manager.manager import PasswordManager
from nostr.client import NostrClient from nostr.client import NostrClient
from password_manager.entry_types import EntryType
from constants import INACTIVITY_TIMEOUT, initialize_app from constants import INACTIVITY_TIMEOUT, initialize_app
from utils.password_prompt import PasswordPromptError from utils.password_prompt import PasswordPromptError
from utils import timed_input from utils import timed_input
from local_bip85.bip85 import Bip85Error from local_bip85.bip85 import Bip85Error
import pyperclip
colorama_init() colorama_init()
@@ -233,6 +235,22 @@ def handle_display_stats(password_manager: PasswordManager) -> None:
print(colored(f"Error: Failed to display stats: {e}", "red")) 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( def handle_post_to_nostr(
password_manager: PasswordManager, alt_summary: str | None = None 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")) 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: def handle_profiles_menu(password_manager: PasswordManager) -> None:
"""Submenu for managing seed profiles.""" """Submenu for managing seed profiles."""
while True: while True:
@@ -566,8 +625,9 @@ def handle_settings(password_manager: PasswordManager) -> None:
print("9. Set additional backup location") print("9. Set additional backup location")
print("10. Set inactivity timeout") print("10. Set inactivity timeout")
print("11. Lock Vault") print("11. Lock Vault")
print("12. Back") print("12. Stats")
print("13. Stats") print("13. Toggle Secret Mode")
print("14. Back")
choice = input("Select an option: ").strip() choice = input("Select an option: ").strip()
if choice == "1": if choice == "1":
handle_profiles_menu(password_manager) 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")) print(colored("Vault locked. Please re-enter your password.", "yellow"))
password_manager.unlock_vault() password_manager.unlock_vault()
elif choice == "12": elif choice == "12":
break
elif choice == "13":
handle_display_stats(password_manager) handle_display_stats(password_manager)
elif choice == "13":
handle_toggle_secret_mode(password_manager)
elif choice == "14":
break
else: else:
print(colored("Invalid choice.", "red")) print(colored("Invalid choice.", "red"))
@@ -615,10 +677,11 @@ def display_menu(
Select an option: Select an option:
1. Add Entry 1. Add Entry
2. Retrieve Entry 2. Retrieve Entry
3. Modify an Existing Entry 3. Search Entries
4. 2FA Codes 4. Modify an Existing Entry
5. Settings 5. 2FA Codes
6. Exit 6. Settings
7. Exit
""" """
display_fn = getattr(password_manager, "display_stats", None) display_fn = getattr(password_manager, "display_stats", None)
if callable(display_fn): if callable(display_fn):
@@ -643,7 +706,7 @@ def display_menu(
print(colored(menu, "cyan")) print(colored(menu, "cyan"))
try: try:
choice = timed_input( choice = timed_input(
"Enter your choice (1-6): ", inactivity_timeout "Enter your choice (1-7): ", inactivity_timeout
).strip() ).strip()
except TimeoutError: except TimeoutError:
print(colored("Session timed out. Vault locked.", "yellow")) print(colored("Session timed out. Vault locked.", "yellow"))
@@ -654,7 +717,7 @@ def display_menu(
if not choice: if not choice:
print( print(
colored( colored(
"No input detected. Please enter a number between 1 and 6.", "No input detected. Please enter a number between 1 and 7.",
"yellow", "yellow",
) )
) )
@@ -682,14 +745,17 @@ def display_menu(
password_manager.handle_retrieve_entry() password_manager.handle_retrieve_entry()
elif choice == "3": elif choice == "3":
password_manager.update_activity() password_manager.update_activity()
password_manager.handle_modify_entry() password_manager.handle_search_entries()
elif choice == "4": elif choice == "4":
password_manager.update_activity() password_manager.update_activity()
password_manager.handle_display_totp_codes() password_manager.handle_modify_entry()
elif choice == "5": elif choice == "5":
password_manager.update_activity() password_manager.update_activity()
handle_settings(password_manager) password_manager.handle_display_totp_codes()
elif choice == "6": elif choice == "6":
password_manager.update_activity()
handle_settings(password_manager)
elif choice == "7":
logging.info("Exiting the program.") logging.info("Exiting the program.")
print(colored("Exiting the program.", "green")) print(colored("Exiting the program.", "green"))
password_manager.nostr_client.close_client_pool() password_manager.nostr_client.close_client_pool()
@@ -698,15 +764,14 @@ def display_menu(
print(colored("Invalid choice. Please select a valid option.", "red")) print(colored("Invalid choice. Please select a valid option.", "red"))
if __name__ == "__main__": def main(argv: list[str] | None = None) -> int:
# Configure logging with both file and console handlers """Entry point for the SeedPass CLI."""
configure_logging() configure_logging()
initialize_app() initialize_app()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.info("Starting SeedPass Password Manager") logger.info("Starting SeedPass Password Manager")
# Load config from disk and parse command-line arguments load_global_config()
cfg = load_global_config()
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command") sub = parser.add_subparsers(dest="command")
@@ -716,48 +781,97 @@ if __name__ == "__main__":
imp = sub.add_parser("import") imp = sub.add_parser("import")
imp.add_argument("--file") 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: try:
password_manager = PasswordManager() password_manager = PasswordManager()
logger.info("PasswordManager initialized successfully.") logger.info("PasswordManager initialized successfully.")
except (PasswordPromptError, Bip85Error) as e: except (PasswordPromptError, Bip85Error) as e:
logger.error(f"Failed to initialize PasswordManager: {e}", exc_info=True) logger.error(f"Failed to initialize PasswordManager: {e}", exc_info=True)
print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red")) print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red"))
sys.exit(1) return 1
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize PasswordManager: {e}", exc_info=True) logger.error(f"Failed to initialize PasswordManager: {e}", exc_info=True)
print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red")) print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red"))
sys.exit(1) return 1
if args.command == "export": if args.command == "export":
password_manager.handle_export_database(Path(args.file)) password_manager.handle_export_database(Path(args.file))
sys.exit(0) return 0
elif args.command == "import": if args.command == "import":
password_manager.handle_import_database(Path(args.file)) 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):
def signal_handler(sig, frame):
"""
Handles termination signals to gracefully shutdown the NostrClient.
"""
print(colored("\nReceived shutdown signal. Exiting gracefully...", "yellow")) print(colored("\nReceived shutdown signal. Exiting gracefully...", "yellow"))
logging.info(f"Received shutdown signal: {sig}. Initiating graceful shutdown.") logging.info(f"Received shutdown signal: {sig}. Initiating graceful shutdown.")
try: try:
password_manager.nostr_client.close_client_pool() # Gracefully close the ClientPool password_manager.nostr_client.close_client_pool()
logging.info("NostrClient closed successfully.") logging.info("NostrClient closed successfully.")
except Exception as e: except Exception as exc:
logging.error(f"Error during shutdown: {e}") logging.error(f"Error during shutdown: {exc}")
print(colored(f"Error during shutdown: {e}", "red")) print(colored(f"Error during shutdown: {exc}", "red"))
sys.exit(0) sys.exit(0)
# Register the signal handlers signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGINT, signal_handler) # Handle Ctrl+C signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGTERM, signal_handler) # Handle termination signals
# Display the interactive menu to the user
try: try:
display_menu( display_menu(
password_manager, inactivity_timeout=password_manager.inactivity_timeout password_manager, inactivity_timeout=password_manager.inactivity_timeout
@@ -766,29 +880,34 @@ if __name__ == "__main__":
logger.info("Program terminated by user via KeyboardInterrupt.") logger.info("Program terminated by user via KeyboardInterrupt.")
print(colored("\nProgram terminated by user.", "yellow")) print(colored("\nProgram terminated by user.", "yellow"))
try: try:
password_manager.nostr_client.close_client_pool() # Gracefully close the ClientPool password_manager.nostr_client.close_client_pool()
logging.info("NostrClient closed successfully.") logging.info("NostrClient closed successfully.")
except Exception as e: except Exception as exc:
logging.error(f"Error during shutdown: {e}") logging.error(f"Error during shutdown: {exc}")
print(colored(f"Error during shutdown: {e}", "red")) print(colored(f"Error during shutdown: {exc}", "red"))
sys.exit(0) return 0
except (PasswordPromptError, Bip85Error) as e: except (PasswordPromptError, Bip85Error) as e:
logger.error(f"A user-related error occurred: {e}", exc_info=True) logger.error(f"A user-related error occurred: {e}", exc_info=True)
print(colored(f"Error: {e}", "red")) print(colored(f"Error: {e}", "red"))
try: try:
password_manager.nostr_client.close_client_pool() password_manager.nostr_client.close_client_pool()
logging.info("NostrClient closed successfully.") logging.info("NostrClient closed successfully.")
except Exception as close_error: except Exception as exc:
logging.error(f"Error during shutdown: {close_error}") logging.error(f"Error during shutdown: {exc}")
print(colored(f"Error during shutdown: {close_error}", "red")) print(colored(f"Error during shutdown: {exc}", "red"))
sys.exit(1) return 1
except Exception as e: except Exception as e:
logger.error(f"An unexpected error occurred: {e}", exc_info=True) logger.error(f"An unexpected error occurred: {e}", exc_info=True)
print(colored(f"Error: An unexpected error occurred: {e}", "red")) print(colored(f"Error: An unexpected error occurred: {e}", "red"))
try: 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.") logging.info("NostrClient closed successfully.")
except Exception as close_error: except Exception as exc:
logging.error(f"Error during shutdown: {close_error}") logging.error(f"Error during shutdown: {exc}")
print(colored(f"Error during shutdown: {close_error}", "red")) print(colored(f"Error during shutdown: {exc}", "red"))
sys.exit(1) return 1
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -45,6 +45,8 @@ class ConfigManager:
"password_hash": "", "password_hash": "",
"inactivity_timeout": INACTIVITY_TIMEOUT, "inactivity_timeout": INACTIVITY_TIMEOUT,
"additional_backup_path": "", "additional_backup_path": "",
"secret_mode_enabled": False,
"clipboard_clear_delay": 45,
} }
try: try:
data = self.vault.load_config() data = self.vault.load_config()
@@ -56,6 +58,8 @@ class ConfigManager:
data.setdefault("password_hash", "") data.setdefault("password_hash", "")
data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT) data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT)
data.setdefault("additional_backup_path", "") 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 # Migrate legacy hashed_password.enc if present and password_hash is missing
legacy_file = self.fingerprint_dir / "hashed_password.enc" legacy_file = self.fingerprint_dir / "hashed_password.enc"
@@ -144,3 +148,27 @@ class ConfigManager:
config = self.load_config(require_pin=False) config = self.load_config(require_pin=False)
value = config.get("additional_backup_path", "") value = config.get("additional_backup_path", "")
return value or None 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))

View File

@@ -385,8 +385,10 @@ class EntryManager:
colored(f"Error: Failed to modify entry at index {index}: {e}", "red") 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]]: def list_entries(
"""List all entries in the index.""" 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: try:
data = self.vault.load_index() data = self.vault.load_index()
entries_data = data.get("entries", {}) entries_data = data.get("entries", {})
@@ -396,17 +398,36 @@ class EntryManager:
print(colored("No entries found.", "yellow")) print(colored("No entries found.", "yellow"))
return [] return []
entries = [] def sort_key(item: Tuple[str, Dict[str, Any]]):
for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])): 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) etype = entry.get("type", EntryType.PASSWORD.value)
if etype == EntryType.TOTP.value: if etype == EntryType.TOTP.value:
entries.append( entries.append((idx, entry.get("label", ""), None, None, False))
(int(idx), entry.get("label", ""), None, None, False)
)
else: else:
entries.append( entries.append(
( (
int(idx), idx,
entry.get("website", ""), entry.get("website", ""),
entry.get("username", ""), entry.get("username", ""),
entry.get("url", ""), entry.get("url", ""),
@@ -415,7 +436,7 @@ class EntryManager:
) )
logger.debug(f"Total entries found: {len(entries)}") 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) etype = entry.get("type", EntryType.PASSWORD.value)
print(colored(f"Index: {idx}", "cyan")) print(colored(f"Index: {idx}", "cyan"))
if etype == EntryType.TOTP.value: if etype == EntryType.TOTP.value:
@@ -449,6 +470,49 @@ class EntryManager:
print(colored(f"Error: Failed to list entries: {e}", "red")) print(colored(f"Error: Failed to list entries: {e}", "red"))
return [] 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: def delete_entry(self, index: int) -> None:
""" """
Deletes an entry based on the provided index. Deletes an entry based on the provided index.
@@ -549,12 +613,12 @@ class EntryManager:
) )
) )
def list_all_entries(self) -> None: def list_all_entries(
""" self, sort_by: str = "index", filter_kind: str | None = None
Displays all entries in a formatted manner. ) -> None:
""" """Display all entries using :meth:`list_entries`."""
try: try:
entries = self.list_entries() entries = self.list_entries(sort_by=sort_by, filter_kind=filter_kind)
if not entries: if not entries:
print(colored("No entries to display.", "yellow")) print(colored("No entries to display.", "yellow"))
return return

View File

@@ -42,6 +42,7 @@ from utils.password_prompt import (
confirm_action, confirm_action,
) )
from utils.memory_protection import InMemorySecret from utils.memory_protection import InMemorySecret
from utils.clipboard import copy_to_clipboard
from constants import MIN_HEALTHY_RELAYS from constants import MIN_HEALTHY_RELAYS
from constants import ( from constants import (
@@ -106,6 +107,8 @@ class PasswordManager:
self.last_activity: float = time.time() self.last_activity: float = time.time()
self.locked: bool = False self.locked: bool = False
self.inactivity_timeout: float = INACTIVITY_TIMEOUT self.inactivity_timeout: float = INACTIVITY_TIMEOUT
self.secret_mode_enabled: bool = False
self.clipboard_clear_delay: int = 45
# Initialize the fingerprint manager first # Initialize the fingerprint manager first
self.initialize_fingerprint_manager() self.initialize_fingerprint_manager()
@@ -776,6 +779,8 @@ class PasswordManager:
self.inactivity_timeout = config.get( self.inactivity_timeout = config.get(
"inactivity_timeout", INACTIVITY_TIMEOUT "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( self.nostr_client = NostrClient(
encryption_manager=self.encryption_manager, encryption_manager=self.encryption_manager,
@@ -1021,9 +1026,18 @@ class PasswordManager:
try: try:
while True: while True:
code = self.entry_manager.get_totp_code(index, self.parent_seed) code = self.entry_manager.get_totp_code(index, self.parent_seed)
print(colored("\n[+] Retrieved 2FA Code:\n", "green")) if self.secret_mode_enabled:
print(colored(f"Label: {label}", "cyan")) copy_to_clipboard(code, self.clipboard_clear_delay)
print(colored(f"Code: {code}", "yellow")) 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: if notes:
print(colored(f"Notes: {notes}", "cyan")) print(colored(f"Notes: {notes}", "cyan"))
remaining = self.entry_manager.get_totp_time_remaining(index) remaining = self.entry_manager.get_totp_time_remaining(index)
@@ -1084,18 +1098,30 @@ class PasswordManager:
password = self.password_generator.generate_password(length, index) password = self.password_generator.generate_password(length, index)
if password: if password:
print( if self.secret_mode_enabled:
colored(f"\n[+] Retrieved Password for {website_name}:\n", "green") copy_to_clipboard(password, self.clipboard_clear_delay)
) print(
print(colored(f"Password: {password}", "yellow")) colored(
print(colored(f"Associated Username: {username or 'N/A'}", "cyan")) f"[+] Password for '{website_name}' copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.",
print(colored(f"Associated URL: {url or 'N/A'}", "cyan")) "green",
print( )
colored( )
f"Blacklist Status: {'Blacklisted' if blacklisted else 'Not Blacklisted'}", else:
"cyan", 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: else:
print(colored("Error: Failed to retrieve the password.", "red")) print(colored("Error: Failed to retrieve the password.", "red"))
except Exception as e: except Exception as e:
@@ -1303,6 +1329,35 @@ class PasswordManager:
logging.error(f"Error during modifying entry: {e}", exc_info=True) logging.error(f"Error during modifying entry: {e}", exc_info=True)
print(colored(f"Error: Failed to modify entry: {e}", "red")) 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: def delete_entry(self) -> None:
"""Deletes an entry from the password index.""" """Deletes an entry from the password index."""
try: try:
@@ -1373,7 +1428,13 @@ class PasswordManager:
remaining = self.entry_manager.get_totp_time_remaining(idx) remaining = self.entry_manager.get_totp_time_remaining(idx)
filled = int(20 * (period - remaining) / period) filled = int(20 * (period - remaining) / period)
bar = "[" + "#" * filled + "-" * (20 - filled) + "]" 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: if imported_list:
print(colored("\nImported 2FA Codes:", "green")) print(colored("\nImported 2FA Codes:", "green"))
for label, idx, period, _ in imported_list: for label, idx, period, _ in imported_list:
@@ -1381,7 +1442,13 @@ class PasswordManager:
remaining = self.entry_manager.get_totp_time_remaining(idx) remaining = self.entry_manager.get_totp_time_remaining(idx)
filled = int(20 * (period - remaining) / period) filled = int(20 * (period - remaining) / period)
bar = "[" + "#" * filled + "-" * (20 - filled) + "]" 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() sys.stdout.flush()
try: try:
if sys.stdin in select.select([sys.stdin], [], [], 1)[0]: if sys.stdin in select.select([sys.stdin], [], [], 1)[0]:

View File

@@ -82,6 +82,7 @@ def export_backup(
json_bytes = json.dumps(wrapper, indent=2).encode("utf-8") json_bytes = json.dumps(wrapper, indent=2).encode("utf-8")
dest_path.write_bytes(json_bytes) dest_path.write_bytes(json_bytes)
os.chmod(dest_path, 0o600) os.chmod(dest_path, 0o600)
backup_manager._create_additional_backup(dest_path)
if publish: if publish:
encrypted = vault.encryption_manager.encrypt_data(json_bytes) encrypted = vault.encryption_manager.encrypt_data(json_bytes)

View File

@@ -21,3 +21,4 @@ mutmut==2.4.4
pyotp>=2.8.0 pyotp>=2.8.0
freezegun freezegun
pyperclip

View File

@@ -20,6 +20,12 @@ def pytest_addoption(parser: pytest.Parser) -> None:
default=False, default=False,
help="run desktop-only tests", 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: def pytest_configure(config: pytest.Config) -> None:

View File

@@ -31,7 +31,7 @@ def test_auto_sync_triggers_post(monkeypatch):
called = True called = True
monkeypatch.setattr(main, "handle_post_to_nostr", fake_post) 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): with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=0.1) main.display_menu(pm, sync_interval=0.1)

View File

@@ -52,7 +52,7 @@ def _make_pm(called, locked=None):
def test_empty_and_non_numeric_choice(monkeypatch, capsys): def test_empty_and_non_numeric_choice(monkeypatch, capsys):
called = {"add": False, "retrieve": False, "modify": False} called = {"add": False, "retrieve": False, "modify": False}
pm, _ = _make_pm(called) pm, _ = _make_pm(called)
inputs = iter(["", "abc", "6"]) inputs = iter(["", "abc", "7"])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) 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): def test_out_of_range_menu(monkeypatch, capsys):
called = {"add": False, "retrieve": False, "modify": False} called = {"add": False, "retrieve": False, "modify": False}
pm, _ = _make_pm(called) pm, _ = _make_pm(called)
inputs = iter(["9", "6"]) inputs = iter(["9", "7"])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) 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): def test_invalid_add_entry_submenu(monkeypatch, capsys):
called = {"add": False, "retrieve": False, "modify": False} called = {"add": False, "retrieve": False, "modify": False}
pm, _ = _make_pm(called) 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(main, "timed_input", lambda *_: next(inputs))
monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
@@ -92,7 +92,7 @@ def test_inactivity_timeout_loop(monkeypatch, capsys):
pm, locked = _make_pm(called) pm, locked = _make_pm(called)
pm.last_activity = 0 pm.last_activity = 0
monkeypatch.setattr(time, "time", lambda: 100.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): with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1) main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1)
out = capsys.readouterr().out out = capsys.readouterr().out

View File

@@ -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

View File

@@ -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"

View File

@@ -130,3 +130,19 @@ def test_additional_backup_path_round_trip():
cfg_mgr.set_additional_backup_path(None) cfg_mgr.set_additional_backup_path(None)
cfg2 = cfg_mgr.load_config(require_pin=False) cfg2 = cfg_mgr.load_config(require_pin=False)
assert cfg2["additional_backup_path"] == "" 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

View File

@@ -36,7 +36,7 @@ def test_inactivity_triggers_lock(monkeypatch):
unlock_vault=unlock_vault, unlock_vault=unlock_vault,
) )
monkeypatch.setattr(main, "timed_input", lambda *_: "6") monkeypatch.setattr(main, "timed_input", lambda *_: "7")
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1) 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, unlock_vault=unlock_vault,
) )
responses = iter([TimeoutError(), "6"]) responses = iter([TimeoutError(), "7"])
def fake_input(*_args, **_kwargs): def fake_input(*_args, **_kwargs):
val = next(responses) val = next(responses)

View File

@@ -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)]

View File

@@ -39,6 +39,7 @@ def test_handle_display_totp_codes(monkeypatch, capsys):
pm.nostr_client = FakeNostrClient() pm.nostr_client = FakeNostrClient()
pm.fingerprint_dir = tmp_path pm.fingerprint_dir = tmp_path
pm.is_dirty = False pm.is_dirty = False
pm.secret_mode_enabled = False
entry_mgr.add_totp("Example", TEST_SEED) 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.nostr_client = FakeNostrClient()
pm.fingerprint_dir = tmp_path pm.fingerprint_dir = tmp_path
pm.is_dirty = False pm.is_dirty = False
pm.secret_mode_enabled = False
entry_mgr.add_totp("Visible", TEST_SEED) entry_mgr.add_totp("Visible", TEST_SEED)
entry_mgr.add_totp("Hidden", TEST_SEED) entry_mgr.add_totp("Hidden", TEST_SEED)

View File

@@ -39,6 +39,7 @@ def test_handle_retrieve_totp_entry(monkeypatch, capsys):
pm.nostr_client = FakeNostrClient() pm.nostr_client = FakeNostrClient()
pm.fingerprint_dir = tmp_path pm.fingerprint_dir = tmp_path
pm.is_dirty = False pm.is_dirty = False
pm.secret_mode_enabled = False
entry_mgr.add_totp("Example", TEST_SEED) entry_mgr.add_totp("Example", TEST_SEED)

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -24,7 +24,7 @@ from nostr.client import NostrClient, Kind, KindStandard
@pytest.mark.desktop @pytest.mark.desktop
@pytest.mark.network @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.""" """Manually explore maximum index size for Nostr backups."""
seed = ( seed = (
"abandon abandon abandon abandon abandon abandon abandon " "abandon abandon abandon abandon abandon abandon abandon "
@@ -47,13 +47,16 @@ def test_nostr_index_size_limits():
entry_mgr = EntryManager(vault, backup_mgr) entry_mgr = EntryManager(vault, backup_mgr)
delay = float(os.getenv("NOSTR_TEST_DELAY", "5")) delay = float(os.getenv("NOSTR_TEST_DELAY", "5"))
max_entries = pytestconfig.getoption("--max-entries")
size = 16 size = 16
batch = 100 batch = 100
entry_count = 0 entry_count = 0
max_payload = 60 * 1024 max_payload = 60 * 1024
try: try:
while True: while max_entries is None or entry_count < max_entries:
for _ in range(batch): for _ in range(batch):
if max_entries is not None and entry_count >= max_entries:
break
entry_mgr.add_entry( entry_mgr.add_entry(
website_name=f"site-{entry_count + 1}", website_name=f"site-{entry_count + 1}",
length=12, length=12,
@@ -85,8 +88,13 @@ def test_nostr_index_size_limits():
) )
retrieved_ok = retrieved == encrypted retrieved_ok = retrieved == encrypted
results.append((entry_count, payload_size, True, retrieved_ok)) results.append((entry_count, payload_size, True, retrieved_ok))
if not retrieved_ok or payload_size > max_payload: if max_entries is not None:
break if entry_count >= max_entries:
break
else:
if not retrieved_ok or payload_size > max_payload:
break
size *= 2 size *= 2
except Exception: except Exception:
results.append((entry_count + 1, None, False, False)) 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(f"Nostr account npub: {npub}")
print("Count | Payload Bytes | Published | Retrieved") print("Count | Payload Bytes | Published | Retrieved")
for cnt, payload, pub, ret in results: 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) synced = sum(1 for _, _, pub, ret in results if pub and ret)
print(f"Successfully synced entries: {synced}") print(f"Successfully synced entries: {synced}")

View File

@@ -1,5 +1,6 @@
import json import json
import base64 import base64
import time
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
@@ -30,13 +31,13 @@ def setup_vault(tmp: Path):
vault = Vault(enc_mgr, tmp) vault = Vault(enc_mgr, tmp)
cfg = ConfigManager(vault, tmp) cfg = ConfigManager(vault, tmp)
backup = BackupManager(tmp, cfg) backup = BackupManager(tmp, cfg)
return vault, backup return vault, backup, cfg
def test_round_trip(monkeypatch): def test_round_trip(monkeypatch):
with TemporaryDirectory() as td: with TemporaryDirectory() as td:
tmp = Path(td) tmp = Path(td)
vault, backup = setup_vault(tmp) vault, backup, _ = setup_vault(tmp)
data = {"pw": 1} data = {"pw": 1}
vault.save_index(data) vault.save_index(data)
@@ -54,7 +55,7 @@ from cryptography.fernet import InvalidToken
def test_corruption_detection(monkeypatch): def test_corruption_detection(monkeypatch):
with TemporaryDirectory() as td: with TemporaryDirectory() as td:
tmp = Path(td) tmp = Path(td)
vault, backup = setup_vault(tmp) vault, backup, _ = setup_vault(tmp)
vault.save_index({"a": 1}) vault.save_index({"a": 1})
path = export_backup(vault, backup, parent_seed=SEED) path = export_backup(vault, backup, parent_seed=SEED)
@@ -72,7 +73,7 @@ def test_corruption_detection(monkeypatch):
def test_import_over_existing(monkeypatch): def test_import_over_existing(monkeypatch):
with TemporaryDirectory() as td: with TemporaryDirectory() as td:
tmp = Path(td) tmp = Path(td)
vault, backup = setup_vault(tmp) vault, backup, _ = setup_vault(tmp)
vault.save_index({"v": 1}) vault.save_index({"v": 1})
path = export_backup(vault, backup, parent_seed=SEED) path = export_backup(vault, backup, parent_seed=SEED)
@@ -86,7 +87,7 @@ def test_import_over_existing(monkeypatch):
def test_checksum_mismatch_detection(monkeypatch): def test_checksum_mismatch_detection(monkeypatch):
with TemporaryDirectory() as td: with TemporaryDirectory() as td:
tmp = Path(td) tmp = Path(td)
vault, backup = setup_vault(tmp) vault, backup, _ = setup_vault(tmp)
vault.save_index({"a": 1}) vault.save_index({"a": 1})
path = export_backup(vault, backup, parent_seed=SEED) 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.""" """Ensure backup round trip works when seed is encrypted with another key."""
with TemporaryDirectory() as td: with TemporaryDirectory() as td:
tmp = Path(td) tmp = Path(td)
vault, backup = setup_vault(tmp) vault, backup, _ = setup_vault(tmp)
vault.save_index({"v": 123}) vault.save_index({"v": 123})
path = export_backup(vault, backup, parent_seed=SEED) path = export_backup(vault, backup, parent_seed=SEED)
vault.save_index({"v": 0}) vault.save_index({"v": 0})
import_backup(vault, backup, path, parent_seed=SEED) import_backup(vault, backup, path, parent_seed=SEED)
assert vault.load_index()["v"] == 123 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

View File

@@ -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 == []

View File

@@ -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)]

View File

@@ -93,7 +93,7 @@ def test_settings_menu_additional_backup(monkeypatch):
tmp_path = Path(tmpdir) tmp_path = Path(tmpdir)
pm, cfg_mgr, fp_mgr = setup_pm(tmp_path, monkeypatch) 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("main.handle_set_additional_backup_location") as handler:
with patch("builtins.input", side_effect=lambda *_: next(inputs)): with patch("builtins.input", side_effect=lambda *_: next(inputs)):
main.handle_settings(pm) main.handle_settings(pm)

View File

@@ -25,6 +25,7 @@ try:
from .password_prompt import prompt_for_password from .password_prompt import prompt_for_password
from .input_utils import timed_input from .input_utils import timed_input
from .memory_protection import InMemorySecret from .memory_protection import InMemorySecret
from .clipboard import copy_to_clipboard
if logger.isEnabledFor(logging.DEBUG): if logger.isEnabledFor(logging.DEBUG):
logger.info("Modules imported successfully.") logger.info("Modules imported successfully.")
@@ -49,4 +50,5 @@ __all__ = [
"prompt_for_password", "prompt_for_password",
"timed_input", "timed_input",
"InMemorySecret", "InMemorySecret",
"copy_to_clipboard",
] ]

16
src/utils/clipboard.py Normal file
View File

@@ -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()