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
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,12 +185,13 @@ 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:**

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)
- [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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,6 +1026,15 @@ class PasswordManager:
try:
while True:
code = self.entry_manager.get_totp_code(index, self.parent_seed)
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"))
@@ -1084,8 +1098,20 @@ class PasswordManager:
password = self.password_generator.generate_password(length, index)
if password:
if self.secret_mode_enabled:
copy_to_clipboard(password, self.clipboard_clear_delay)
print(
colored(f"\n[+] Retrieved Password for {website_name}:\n", "green")
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"))
@@ -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,6 +1428,12 @@ class PasswordManager:
remaining = self.entry_manager.get_totp_time_remaining(idx)
filled = int(20 * (period - remaining) / period)
bar = "[" + "#" * filled + "-" * (20 - filled) + "]"
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"))
@@ -1381,6 +1442,12 @@ class PasswordManager:
remaining = self.entry_manager.get_totp_time_remaining(idx)
filled = int(20 * (period - remaining) / period)
bar = "[" + "#" * filled + "-" * (20 - filled) + "]"
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:

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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.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)

View File

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

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.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 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}")

View File

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

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

View File

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