mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-10 00:09:04 +00:00
35
README.md
35
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:**
|
||||
|
@@ -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.
|
||||
- `<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.
|
||||
- `--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
|
||||
|
@@ -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
|
||||
|
221
src/main.py
221
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())
|
||||
|
@@ -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))
|
||||
|
@@ -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
|
||||
|
@@ -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]:
|
||||
|
@@ -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)
|
||||
|
@@ -21,3 +21,4 @@ mutmut==2.4.4
|
||||
pyotp>=2.8.0
|
||||
|
||||
freezegun
|
||||
pyperclip
|
||||
|
@@ -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:
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
131
src/tests/test_cli_subcommands.py
Normal file
131
src/tests/test_cli_subcommands.py
Normal 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
|
68
src/tests/test_clipboard_utils.py
Normal file
68
src/tests/test_clipboard_utils.py
Normal 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"
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
55
src/tests/test_list_entries_sort_filter.py
Normal file
55
src/tests/test_list_entries_sort_filter.py
Normal 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)]
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
||||
|
49
src/tests/test_menu_options.py
Normal file
49
src/tests/test_menu_options.py
Normal 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"]
|
38
src/tests/test_menu_search.py
Normal file
38
src/tests/test_menu_search.py
Normal 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"]
|
@@ -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}")
|
||||
|
@@ -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
|
||||
|
81
src/tests/test_search_entries.py
Normal file
81
src/tests/test_search_entries.py
Normal 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 == []
|
82
src/tests/test_secret_mode.py
Normal file
82
src/tests/test_secret_mode.py
Normal 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)]
|
@@ -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)
|
||||
|
@@ -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",
|
||||
]
|
||||
|
16
src/utils/clipboard.py
Normal file
16
src/utils/clipboard.py
Normal 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()
|
Reference in New Issue
Block a user