Merge pull request #568 from PR0M3TH3AN/beta

Beta
This commit is contained in:
thePR0M3TH3AN
2025-07-15 13:09:02 -04:00
committed by GitHub
8 changed files with 943 additions and 456 deletions

View File

@@ -331,6 +331,15 @@ When **Secret Mode** is enabled, SeedPass copies retrieved passwords directly to
2. Choose how many seconds to keep passwords on the clipboard. 2. Choose how many seconds to keep passwords on the clipboard.
3. Retrieve an entry and SeedPass will confirm the password was copied. 3. Retrieve an entry and SeedPass will confirm the password was copied.
### Viewing Entry Details
Selecting an item from **List Entries** or **Search Entries** first displays the
entry's metadata such as the label, username, tags and notes. Passwords, seed
phrases and other sensitive fields remain hidden until you choose to reveal
them. When you opt to show the secret, the details view presents the same action
menu as **Retrieve Entry** so you can edit, archive or display QR codes for the
entry.
### Additional Entry Types ### Additional Entry Types
SeedPass supports storing more than just passwords and 2FA secrets. You can also create entries for: SeedPass supports storing more than just passwords and 2FA secrets. You can also create entries for:

View File

@@ -156,6 +156,14 @@ $ seedpass entry get "email"
Code: 123456 Code: 123456
``` ```
### Viewing Entry Details
Picking an entry from `entry list` or `entry search` displays its metadata first
so you can review the label, username and notes. Sensitive fields are hidden
until you confirm you want to reveal them. After showing the secret, the details
view offers the same actions as `entry get`—edit the entry, archive it or show
QR codes for supported types.
### `vault` Commands ### `vault` Commands
- **`seedpass vault export`** Export the entire vault to an encrypted JSON file. - **`seedpass vault export`** Export the entire vault to an encrypted JSON file.

View File

@@ -437,8 +437,8 @@ class EntryManager:
"""Return the npub and nsec for the specified entry.""" """Return the npub and nsec for the specified entry."""
entry = self.retrieve_entry(index) entry = self.retrieve_entry(index)
etype = entry.get("type") if entry else None etype = entry.get("type", "").lower() if entry else ""
kind = entry.get("kind") if entry else None kind = entry.get("kind", "").lower() if entry else ""
if not entry or ( if not entry or (
etype != EntryType.NOSTR.value and kind != EntryType.NOSTR.value etype != EntryType.NOSTR.value and kind != EntryType.NOSTR.value
): ):

View File

@@ -153,6 +153,7 @@ class PasswordManager:
self.profile_stack: list[tuple[str, Path, str]] = [] self.profile_stack: list[tuple[str, Path, str]] = []
self.last_unlock_duration: float | None = None self.last_unlock_duration: float | None = None
self.verbose_timing: bool = False self.verbose_timing: bool = False
self._suppress_entry_actions_menu: bool = False
# Initialize the fingerprint manager first # Initialize the fingerprint manager first
self.initialize_fingerprint_manager() self.initialize_fingerprint_manager()
@@ -1792,24 +1793,32 @@ class PasswordManager:
pause() pause()
def show_entry_details_by_index(self, index: int) -> None: def show_entry_details_by_index(self, index: int) -> None:
"""Display entry details using :meth:`handle_retrieve_entry` for the """Display details for entry ``index`` and offer actions."""
given index without prompting for it again."""
original_input = builtins.input
first_call = True
def patched_input(prompt: str = "") -> str:
nonlocal first_call
if first_call:
first_call = False
return str(index)
return original_input(prompt)
try: try:
builtins.input = patched_input entry = self.entry_manager.retrieve_entry(index)
self.handle_retrieve_entry() if not entry:
finally: return
builtins.input = original_input
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_header_with_notification(
self,
fp,
"Entry Details",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
self.display_entry_details(index)
if confirm_action("Show sensitive information? (y/N): "):
self.display_sensitive_entry_info(entry, index)
pause()
self._entry_actions_menu(index, entry)
except Exception as e:
logging.error(f"Failed to display entry details: {e}", exc_info=True)
print(colored(f"Error: Failed to display entry details: {e}", "red"))
pause()
def _prompt_toggle_archive(self, entry: dict, index: int) -> None: def _prompt_toggle_archive(self, entry: dict, index: int) -> None:
"""Prompt the user to archive or restore ``entry`` based on its status.""" """Prompt the user to archive or restore ``entry`` based on its status."""
@@ -1828,6 +1837,13 @@ class PasswordManager:
self.is_dirty = True self.is_dirty = True
self.last_update = time.time() self.last_update = time.time()
def _entry_type_str(self, entry: dict) -> str:
"""Return the entry type as a lowercase string."""
entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
if isinstance(entry_type, EntryType):
entry_type = entry_type.value
return str(entry_type).lower()
def _entry_actions_menu(self, index: int, entry: dict) -> None: def _entry_actions_menu(self, index: int, entry: dict) -> None:
"""Provide actions for a retrieved entry.""" """Provide actions for a retrieved entry."""
while True: while True:
@@ -1840,7 +1856,7 @@ class PasswordManager:
child_fingerprint=child_fp, child_fingerprint=child_fp,
) )
archived = entry.get("archived", entry.get("blacklisted", False)) archived = entry.get("archived", entry.get("blacklisted", False))
entry_type = entry.get("type", EntryType.PASSWORD.value) entry_type = self._entry_type_str(entry)
print(colored("\n[+] Entry Actions:", "green")) print(colored("\n[+] Entry Actions:", "green"))
if archived: if archived:
print(colored("U. Unarchive", "cyan")) print(colored("U. Unarchive", "cyan"))
@@ -1916,13 +1932,14 @@ class PasswordManager:
self._entry_edit_menu(index, entry) self._entry_edit_menu(index, entry)
elif choice == "q": elif choice == "q":
self._entry_qr_menu(index, entry) self._entry_qr_menu(index, entry)
pause()
else: else:
print(colored("Invalid choice.", "red")) print(colored("Invalid choice.", "red"))
entry = self.entry_manager.retrieve_entry(index) or entry entry = self.entry_manager.retrieve_entry(index) or entry
def _entry_edit_menu(self, index: int, entry: dict) -> None: def _entry_edit_menu(self, index: int, entry: dict) -> None:
"""Sub-menu for editing common entry fields.""" """Sub-menu for editing common entry fields."""
entry_type = entry.get("type", EntryType.PASSWORD.value) entry_type = self._entry_type_str(entry)
while True: while True:
fp, parent_fp, child_fp = self.header_fingerprint_args fp, parent_fp, child_fp = self.header_fingerprint_args
clear_header_with_notification( clear_header_with_notification(
@@ -1982,7 +1999,7 @@ class PasswordManager:
def _entry_qr_menu(self, index: int, entry: dict) -> None: def _entry_qr_menu(self, index: int, entry: dict) -> None:
"""Display QR codes for the given ``entry``.""" """Display QR codes for the given ``entry``."""
entry_type = entry.get("type") entry_type = self._entry_type_str(entry)
try: try:
if entry_type in {EntryType.SEED.value, EntryType.MANAGED_ACCOUNT.value}: if entry_type in {EntryType.SEED.value, EntryType.MANAGED_ACCOUNT.value}:
@@ -1997,6 +2014,7 @@ class PasswordManager:
from password_manager.seedqr import encode_seedqr from password_manager.seedqr import encode_seedqr
TotpManager.print_qr_code(encode_seedqr(seed)) TotpManager.print_qr_code(encode_seedqr(seed))
pause()
return return
if entry_type == EntryType.NOSTR.value: if entry_type == EntryType.NOSTR.value:
@@ -2032,6 +2050,7 @@ class PasswordManager:
TotpManager.print_qr_code(nsec) TotpManager.print_qr_code(nsec)
else: else:
print(colored("Invalid choice.", "red")) print(colored("Invalid choice.", "red"))
pause()
entry = self.entry_manager.retrieve_entry(index) or entry entry = self.entry_manager.retrieve_entry(index) or entry
return return
@@ -2040,35 +2059,20 @@ class PasswordManager:
logging.error(f"Error displaying QR menu: {e}", exc_info=True) logging.error(f"Error displaying QR menu: {e}", exc_info=True)
print(colored(f"Error: Failed to display QR codes: {e}", "red")) print(colored(f"Error: Failed to display QR codes: {e}", "red"))
def handle_retrieve_entry(self) -> None: def display_sensitive_entry_info(self, entry: dict, index: int) -> None:
""" """Display information for a sensitive entry.
Handles retrieving a password from the index by prompting the user for the index number
and displaying the corresponding password and associated details.
"""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_header_with_notification(
self,
fp,
"Main Menu > Retrieve Entry",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
index_input = input(
"Enter the index number of the entry to retrieve: "
).strip()
if not index_input.isdigit():
print(colored("Error: Index must be a number.", "red"))
pause()
return
index = int(index_input)
entry = self.entry_manager.retrieve_entry(index) Parameters
if not entry: ----------
pause() entry: dict
return Entry data retrieved from the vault.
index: int
Index of the entry being displayed.
"""
entry_type = entry.get("type", EntryType.PASSWORD.value) self._suppress_entry_actions_menu = False
entry_type = self._entry_type_str(entry)
if entry_type == EntryType.TOTP.value: if entry_type == EntryType.TOTP.value:
label = entry.get("label", "") label = entry.get("label", "")
@@ -2124,12 +2128,11 @@ class PasswordManager:
sys.stdout.flush() sys.stdout.flush()
if exit_loop: if exit_loop:
break break
except Exception as e: except Exception as e: # pragma: no cover - best effort
logging.error(f"Error generating TOTP code: {e}", exc_info=True) logging.error(f"Error generating TOTP code: {e}", exc_info=True)
print(colored(f"Error: Failed to generate TOTP code: {e}", "red")) print(colored(f"Error: Failed to generate TOTP code: {e}", "red"))
self._entry_actions_menu(index, entry)
pause()
return return
if entry_type == EntryType.SSH.value: if entry_type == EntryType.SSH.value:
notes = entry.get("notes", "") notes = entry.get("notes", "")
label = entry.get("label", "") label = entry.get("label", "")
@@ -2163,12 +2166,11 @@ class PasswordManager:
else: else:
print(colored("Private Key:", "cyan")) print(colored("Private Key:", "cyan"))
print(color_text(priv_pem, "deterministic")) print(color_text(priv_pem, "deterministic"))
except Exception as e: except Exception as e: # pragma: no cover - best effort
logging.error(f"Error deriving SSH key pair: {e}", exc_info=True) logging.error(f"Error deriving SSH key pair: {e}", exc_info=True)
print(colored(f"Error: Failed to derive SSH keys: {e}", "red")) print(colored(f"Error: Failed to derive SSH keys: {e}", "red"))
self._entry_actions_menu(index, entry)
pause()
return return
if entry_type == EntryType.SEED.value: if entry_type == EntryType.SEED.value:
notes = entry.get("notes", "") notes = entry.get("notes", "")
label = entry.get("label", "") label = entry.get("label", "")
@@ -2198,7 +2200,6 @@ class PasswordManager:
) )
else: else:
print(color_text(phrase, "deterministic")) print(color_text(phrase, "deterministic"))
# Removed QR code display prompt and output
if confirm_action("Show derived entropy as hex? (Y/N): "): if confirm_action("Show derived entropy as hex? (Y/N): "):
from local_bip85.bip85 import BIP85 from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator from bip_utils import Bip39SeedGenerator
@@ -2214,12 +2215,11 @@ class PasswordManager:
words_len=words, words_len=words,
) )
print(color_text(f"Entropy: {entropy.hex()}", "deterministic")) print(color_text(f"Entropy: {entropy.hex()}", "deterministic"))
except Exception as e: except Exception as e: # pragma: no cover - best effort
logging.error(f"Error deriving seed phrase: {e}", exc_info=True) logging.error(f"Error deriving seed phrase: {e}", exc_info=True)
print(colored(f"Error: Failed to derive seed phrase: {e}", "red")) print(colored(f"Error: Failed to derive seed phrase: {e}", "red"))
self._entry_actions_menu(index, entry)
pause()
return return
if entry_type == EntryType.PGP.value: if entry_type == EntryType.PGP.value:
notes = entry.get("notes", "") notes = entry.get("notes", "")
label = entry.get("user_id", "") label = entry.get("user_id", "")
@@ -2251,12 +2251,11 @@ class PasswordManager:
) )
else: else:
print(color_text(priv_key, "deterministic")) print(color_text(priv_key, "deterministic"))
except Exception as e: except Exception as e: # pragma: no cover - best effort
logging.error(f"Error deriving PGP key: {e}", exc_info=True) logging.error(f"Error deriving PGP key: {e}", exc_info=True)
print(colored(f"Error: Failed to derive PGP key: {e}", "red")) print(colored(f"Error: Failed to derive PGP key: {e}", "red"))
self._entry_actions_menu(index, entry)
pause()
return return
if entry_type == EntryType.NOSTR.value: if entry_type == EntryType.NOSTR.value:
label = entry.get("label", "") label = entry.get("label", "")
notes = entry.get("notes", "") notes = entry.get("notes", "")
@@ -2277,17 +2276,14 @@ class PasswordManager:
) )
else: else:
print(color_text(f"nsec: {nsec}", "deterministic")) print(color_text(f"nsec: {nsec}", "deterministic"))
# QR code display removed for npub and nsec
if notes: if notes:
print(colored(f"Notes: {notes}", "cyan")) print(colored(f"Notes: {notes}", "cyan"))
tags = entry.get("tags", []) tags = entry.get("tags", [])
if tags: if tags:
print(colored(f"Tags: {', '.join(tags)}", "cyan")) print(colored(f"Tags: {', '.join(tags)}", "cyan"))
except Exception as e: except Exception as e: # pragma: no cover - best effort
logging.error(f"Error deriving Nostr keys: {e}", exc_info=True) logging.error(f"Error deriving Nostr keys: {e}", exc_info=True)
print(colored(f"Error: Failed to derive Nostr keys: {e}", "red")) print(colored(f"Error: Failed to derive Nostr keys: {e}", "red"))
self._entry_actions_menu(index, entry)
pause()
return return
if entry_type == EntryType.KEY_VALUE.value: if entry_type == EntryType.KEY_VALUE.value:
@@ -2303,8 +2299,7 @@ class PasswordManager:
print(colored(f"Tags: {', '.join(tags)}", "cyan")) print(colored(f"Tags: {', '.join(tags)}", "cyan"))
print( print(
colored( colored(
f"Archived Status: {'Archived' if archived else 'Active'}", f"Archived Status: {'Archived' if archived else 'Active'}", "cyan"
"cyan",
) )
) )
if self.secret_mode_enabled: if self.secret_mode_enabled:
@@ -2335,9 +2330,7 @@ class PasswordManager:
if show == "y": if show == "y":
for f_label, f_value in hidden_fields: for f_label, f_value in hidden_fields:
if self.secret_mode_enabled: if self.secret_mode_enabled:
copy_to_clipboard( copy_to_clipboard(f_value, self.clipboard_clear_delay)
f_value, self.clipboard_clear_delay
)
print( print(
colored( colored(
f"[+] {f_label} copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", f"[+] {f_label} copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.",
@@ -2346,9 +2339,8 @@ class PasswordManager:
) )
else: else:
print(colored(f" {f_label}: {f_value}", "cyan")) print(colored(f" {f_label}: {f_value}", "cyan"))
self._entry_actions_menu(index, entry)
pause()
return return
if entry_type == EntryType.MANAGED_ACCOUNT.value: if entry_type == EntryType.MANAGED_ACCOUNT.value:
label = entry.get("label", "") label = entry.get("label", "")
notes = entry.get("notes", "") notes = entry.get("notes", "")
@@ -2364,8 +2356,7 @@ class PasswordManager:
print(colored(f"Tags: {', '.join(tags)}", "cyan")) print(colored(f"Tags: {', '.join(tags)}", "cyan"))
print( print(
colored( colored(
f"Archived Status: {'Archived' if archived else 'Active'}", f"Archived Status: {'Archived' if archived else 'Active'}", "cyan"
"cyan",
) )
) )
action = ( action = (
@@ -2389,17 +2380,14 @@ class PasswordManager:
) )
else: else:
print(color_text(seed, "deterministic")) print(color_text(seed, "deterministic"))
# QR code display removed for managed account seed
self._entry_actions_menu(index, entry)
pause()
return return
if action == "l": if action == "l":
self._suppress_entry_actions_menu = True
self.load_managed_account(index) self.load_managed_account(index)
return return
self._entry_actions_menu(index, entry)
pause()
return return
# Default: PASSWORD
website_name = entry.get("label", entry.get("website")) website_name = entry.get("label", entry.get("website"))
length = entry.get("length") length = entry.get("length")
username = entry.get("username") username = entry.get("username")
@@ -2466,15 +2454,11 @@ class PasswordManager:
else: else:
print(colored(f" {label}: {value}", "cyan")) print(colored(f" {label}: {value}", "cyan"))
if hidden_fields: if hidden_fields:
show = ( show = input("Reveal hidden fields? (y/N): ").strip().lower()
input("Reveal hidden fields? (y/N): ").strip().lower()
)
if show == "y": if show == "y":
for label, value in hidden_fields: for label, value in hidden_fields:
if self.secret_mode_enabled: if self.secret_mode_enabled:
copy_to_clipboard( copy_to_clipboard(value, self.clipboard_clear_delay)
value, self.clipboard_clear_delay
)
print( print(
colored( colored(
f"[+] {label} copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", f"[+] {label} copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.",
@@ -2485,8 +2469,37 @@ class PasswordManager:
print(colored(f" {label}: {value}", "cyan")) print(colored(f" {label}: {value}", "cyan"))
else: else:
print(colored("Error: Failed to retrieve the password.", "red")) print(colored("Error: Failed to retrieve the password.", "red"))
return
def handle_retrieve_entry(self) -> None:
"""Prompt for an index and display the corresponding entry."""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_header_with_notification(
self,
fp,
"Main Menu > Retrieve Entry",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
index_input = input(
"Enter the index number of the entry to retrieve: "
).strip()
if not index_input.isdigit():
print(colored("Error: Index must be a number.", "red"))
pause()
return
index = int(index_input)
entry = self.entry_manager.retrieve_entry(index)
if not entry:
pause()
return
self.display_sensitive_entry_info(entry, index)
self._entry_actions_menu(index, entry) self._entry_actions_menu(index, entry)
pause() pause()
return
except Exception as e: except Exception as e:
logging.error(f"Error during password retrieval: {e}", exc_info=True) logging.error(f"Error during password retrieval: {e}", exc_info=True)
print(colored(f"Error: Failed to retrieve password: {e}", "red")) print(colored(f"Error: Failed to retrieve password: {e}", "red"))
@@ -2519,7 +2532,7 @@ class PasswordManager:
if not entry: if not entry:
return return
entry_type = entry.get("type", EntryType.PASSWORD.value) entry_type = self._entry_type_str(entry)
if entry_type == EntryType.TOTP.value: if entry_type == EntryType.TOTP.value:
label = entry.get("label", "") label = entry.get("label", "")
@@ -2912,7 +2925,7 @@ class PasswordManager:
if not entry: if not entry:
return return
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) etype = self._entry_type_str(entry)
print(color_text(f"Index: {index}", "index")) print(color_text(f"Index: {index}", "index"))
if etype == EntryType.TOTP.value: if etype == EntryType.TOTP.value:
print(color_text(f" Label: {entry.get('label', '')}", "index")) print(color_text(f" Label: {entry.get('label', '')}", "index"))
@@ -2934,9 +2947,13 @@ class PasswordManager:
elif etype == EntryType.SEED.value: elif etype == EntryType.SEED.value:
print(color_text(" Type: Seed Phrase", "index")) print(color_text(" Type: Seed Phrase", "index"))
print(color_text(f" Label: {entry.get('label', '')}", "index")) print(color_text(f" Label: {entry.get('label', '')}", "index"))
print(color_text(f" Words: {entry.get('words', 24)}", "index")) words = entry.get("word_count", entry.get("words", 24))
print(color_text(f" Words: {words}", "index"))
print( print(
color_text(f" Derivation Index: {entry.get('index', index)}", "index") color_text(
f" Derivation Index: {entry.get('index', index)}",
"index",
)
) )
notes = entry.get("notes", "") notes = entry.get("notes", "")
if notes: if notes:
@@ -2950,6 +2967,12 @@ class PasswordManager:
print( print(
color_text(f" Derivation Index: {entry.get('index', index)}", "index") color_text(f" Derivation Index: {entry.get('index', index)}", "index")
) )
pub_label = entry.get("public_key_label", "")
if pub_label:
print(color_text(f" Public Key Label: {pub_label}", "index"))
ssh_fingerprint = entry.get("fingerprint", "")
if ssh_fingerprint:
print(color_text(f" Fingerprint: {ssh_fingerprint}", "index"))
notes = entry.get("notes", "") notes = entry.get("notes", "")
if notes: if notes:
print(color_text(f" Notes: {notes}", "index")) print(color_text(f" Notes: {notes}", "index"))
@@ -2968,6 +2991,14 @@ class PasswordManager:
print( print(
color_text(f" Derivation Index: {entry.get('index', index)}", "index") color_text(f" Derivation Index: {entry.get('index', index)}", "index")
) )
try:
_priv, pgp_fp = self.entry_manager.get_pgp_key(index, self.parent_seed)
if pgp_fp:
print(color_text(f" Fingerprint: {pgp_fp}", "index"))
except Exception as pgp_err: # pragma: no cover - best effort logging
logging.error(
f"Failed to derive PGP fingerprint: {pgp_err}", exc_info=True
)
notes = entry.get("notes", "") notes = entry.get("notes", "")
if notes: if notes:
print(color_text(f" Notes: {notes}", "index")) print(color_text(f" Notes: {notes}", "index"))
@@ -2986,6 +3017,37 @@ class PasswordManager:
tags = entry.get("tags", []) tags = entry.get("tags", [])
if tags: if tags:
print(color_text(f" Tags: {', '.join(tags)}", "index")) print(color_text(f" Tags: {', '.join(tags)}", "index"))
elif etype == EntryType.KEY_VALUE.value:
print(color_text(" Type: Key/Value", "index"))
print(color_text(f" Label: {entry.get('label', '')}", "index"))
print(color_text(f" Value: {entry.get('value', '')}", "index"))
notes = entry.get("notes", "")
if notes:
print(color_text(f" Notes: {notes}", "index"))
tags = entry.get("tags", [])
if tags:
print(color_text(f" Tags: {', '.join(tags)}", "index"))
blacklisted = entry.get("archived", entry.get("blacklisted", False))
print(color_text(f" Archived: {'Yes' if blacklisted else 'No'}", "index"))
elif etype == EntryType.MANAGED_ACCOUNT.value:
print(color_text(" Type: Managed Account", "index"))
print(color_text(f" Label: {entry.get('label', '')}", "index"))
words = entry.get("word_count", entry.get("words", 24))
print(color_text(f" Words: {words}", "index"))
print(
color_text(f" Derivation Index: {entry.get('index', index)}", "index")
)
fingerprint = entry.get("fingerprint", "")
if fingerprint:
print(color_text(f" Fingerprint: {fingerprint}", "index"))
notes = entry.get("notes", "")
if notes:
print(color_text(f" Notes: {notes}", "index"))
tags = entry.get("tags", [])
if tags:
print(color_text(f" Tags: {', '.join(tags)}", "index"))
blacklisted = entry.get("archived", entry.get("blacklisted", False))
print(color_text(f" Archived: {'Yes' if blacklisted else 'No'}", "index"))
else: else:
website = entry.get("label", entry.get("website", "")) website = entry.get("label", entry.get("website", ""))
username = entry.get("username", "") username = entry.get("username", "")
@@ -3218,7 +3280,9 @@ class PasswordManager:
entries = data.get("entries", {}) entries = data.get("entries", {})
totp_list: list[tuple[str, int, int, bool]] = [] totp_list: list[tuple[str, int, int, bool]] = []
for idx_str, entry in entries.items(): for idx_str, entry in entries.items():
if entry.get("type") == EntryType.TOTP.value and not entry.get( if self._entry_type_str(
entry
) == EntryType.TOTP.value and not entry.get(
"archived", entry.get("blacklisted", False) "archived", entry.get("blacklisted", False)
): ):
label = entry.get("label", "") label = entry.get("label", "")
@@ -3514,7 +3578,7 @@ class PasswordManager:
totp_entries = [] totp_entries = []
for entry in entries.values(): for entry in entries.values():
if entry.get("type") == EntryType.TOTP.value: if self._entry_type_str(entry) == EntryType.TOTP.value:
label = entry.get("label", "") label = entry.get("label", "")
period = int(entry.get("period", 30)) period = int(entry.get("period", 30))
digits = int(entry.get("digits", 6)) digits = int(entry.get("digits", 6))
@@ -3810,7 +3874,7 @@ class PasswordManager:
entries = data.get("entries", {}) entries = data.get("entries", {})
counts: dict[str, int] = {etype.value: 0 for etype in EntryType} counts: dict[str, int] = {etype.value: 0 for etype in EntryType}
for entry in entries.values(): for entry in entries.values():
etype = entry.get("type", EntryType.PASSWORD.value) etype = self._entry_type_str(entry)
counts[etype] = counts.get(etype, 0) + 1 counts[etype] = counts.get(etype, 0) + 1
stats["entries"] = counts stats["entries"] = counts
stats["total_entries"] = len(entries) stats["total_entries"] = len(entries)

View File

@@ -2,6 +2,8 @@ from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from types import SimpleNamespace from types import SimpleNamespace
import pytest
from helpers import create_vault, TEST_SEED, TEST_PASSWORD from helpers import create_vault, TEST_SEED, TEST_PASSWORD
import sys import sys
@@ -11,6 +13,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager from password_manager.entry_management import EntryManager
from password_manager.backup import BackupManager from password_manager.backup import BackupManager
from password_manager.manager import PasswordManager, EncryptionMode from password_manager.manager import PasswordManager, EncryptionMode
from password_manager.entry_types import EntryType
from password_manager.config_manager import ConfigManager from password_manager.config_manager import ConfigManager
@@ -80,12 +83,314 @@ def test_list_entries_show_details(monkeypatch, capsys):
lambda *a, **k: "b", lambda *a, **k: "b",
) )
inputs = iter(["1", "0", "n"]) inputs = iter(["1", "0"])
monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
pm.handle_list_entries() pm.handle_list_entries()
out = capsys.readouterr().out out = capsys.readouterr().out
assert "Retrieved 2FA Code" in out assert "Label: Example" in out
assert "123456" in out assert "Period: 30s" in out
assert "API" in out assert "API" in out
assert "acct" in out assert "acct" in out
def test_show_entry_details_by_index(monkeypatch):
"""Ensure entry details screen triggers expected calls."""
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
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.nostr_client = SimpleNamespace()
pm.fingerprint_dir = tmp_path
index = entry_mgr.add_entry("example.com", 12)
header_calls = []
monkeypatch.setattr(
"password_manager.manager.clear_header_with_notification",
lambda *a, **k: header_calls.append(True),
)
call_order = []
monkeypatch.setattr(
pm,
"display_entry_details",
lambda *a, **k: call_order.append("display"),
)
monkeypatch.setattr(
pm,
"_entry_actions_menu",
lambda *a, **k: call_order.append("actions"),
)
monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None)
monkeypatch.setattr(
"password_manager.manager.confirm_action", lambda *a, **k: False
)
pm.show_entry_details_by_index(index)
assert len(header_calls) == 1
assert call_order == ["display", "actions"]
def _setup_manager(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.parent_seed = TEST_SEED
pm.nostr_client = SimpleNamespace()
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
return pm, entry_mgr
def _detail_common(monkeypatch, pm):
monkeypatch.setattr(
"password_manager.manager.clear_header_with_notification",
lambda *a, **k: None,
)
monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None)
monkeypatch.setattr(
"password_manager.manager.confirm_action", lambda *a, **k: False
)
called = []
monkeypatch.setattr(pm, "_entry_actions_menu", lambda *a, **k: called.append(True))
return called
def test_show_seed_entry_details(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
idx = entry_mgr.add_seed("seed", TEST_SEED, words_num=12)
called = _detail_common(monkeypatch, pm)
pm.show_entry_details_by_index(idx)
out = capsys.readouterr().out
assert "Type: Seed Phrase" in out
assert "Label: seed" in out
assert "Words: 12" in out
assert f"Derivation Index: {idx}" in out
assert called == [True]
def test_show_ssh_entry_details(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
idx = entry_mgr.add_ssh_key("ssh", TEST_SEED)
data = entry_mgr._load_index(force_reload=True)
data["entries"][str(idx)]["public_key_label"] = "server"
data["entries"][str(idx)]["fingerprint"] = "abc123"
entry_mgr._save_index(data)
called = _detail_common(monkeypatch, pm)
pm.show_entry_details_by_index(idx)
out = capsys.readouterr().out
assert "Type: SSH Key" in out
assert "Label: ssh" in out
assert f"Derivation Index: {idx}" in out
assert "server" in out
assert "abc123" in out
assert called == [True]
def test_show_pgp_entry_details(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
idx = entry_mgr.add_pgp_key("pgp", TEST_SEED, user_id="test")
_k, fp = entry_mgr.get_pgp_key(idx, TEST_SEED)
called = _detail_common(monkeypatch, pm)
pm.show_entry_details_by_index(idx)
out = capsys.readouterr().out
assert "Type: PGP Key" in out
assert "Label: pgp" in out
assert "Key Type: ed25519" in out
assert "User ID: test" in out
assert f"Derivation Index: {idx}" in out
assert fp in out
assert called == [True]
def test_show_nostr_entry_details(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
idx = entry_mgr.add_nostr_key("nostr")
called = _detail_common(monkeypatch, pm)
pm.show_entry_details_by_index(idx)
out = capsys.readouterr().out
assert "Type: Nostr Key" in out
assert "Label: nostr" in out
assert f"Derivation Index: {idx}" in out
assert called == [True]
def test_show_managed_account_entry_details(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
idx = entry_mgr.add_managed_account("acct", TEST_SEED)
fp = entry_mgr.retrieve_entry(idx).get("fingerprint")
called = _detail_common(monkeypatch, pm)
pm.show_entry_details_by_index(idx)
out = capsys.readouterr().out
assert "Type: Managed Account" in out
assert "Label: acct" in out
assert f"Derivation Index: {idx}" in out
assert "Words: 12" in out
assert fp in out
assert called == [True]
@pytest.mark.parametrize(
"entry_type",
[
"password",
"seed",
"ssh",
"pgp",
"nostr",
"totp",
"key_value",
"managed_account",
],
)
def test_show_entry_details_sensitive(monkeypatch, capsys, entry_type):
"""Ensure sensitive details are displayed for each entry type."""
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw123")
monkeypatch.setattr(
"password_manager.manager.confirm_action", lambda *a, **k: True
)
monkeypatch.setattr(
"password_manager.manager.copy_to_clipboard", lambda *a, **k: None
)
monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b")
monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None)
monkeypatch.setattr(
"password_manager.manager.TotpManager.print_qr_code", lambda *a, **k: None
)
monkeypatch.setattr(
"password_manager.manager.clear_header_with_notification",
lambda *a, **k: None,
)
monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None)
input_val = "r" if entry_type == "managed_account" else ""
monkeypatch.setattr("builtins.input", lambda *a, **k: input_val)
called = []
monkeypatch.setattr(
pm, "_entry_actions_menu", lambda *a, **k: called.append(True)
)
if entry_type == "password":
idx = entry_mgr.add_entry("example", 8)
expected = "pw123"
elif entry_type == "seed":
idx = entry_mgr.add_seed("seed", TEST_SEED, words_num=12)
expected = entry_mgr.get_seed_phrase(idx, TEST_SEED)
elif entry_type == "ssh":
idx = entry_mgr.add_ssh_key("ssh", TEST_SEED)
priv, pub = entry_mgr.get_ssh_key_pair(idx, TEST_SEED)
expected = priv
extra = pub
elif entry_type == "pgp":
idx = entry_mgr.add_pgp_key("pgp", TEST_SEED, user_id="test")
priv, fp = entry_mgr.get_pgp_key(idx, TEST_SEED)
expected = priv
extra = fp
elif entry_type == "nostr":
idx = entry_mgr.add_nostr_key("nostr")
_npub, nsec = entry_mgr.get_nostr_key_pair(idx, TEST_SEED)
expected = nsec
elif entry_type == "totp":
entry_mgr.add_totp("Example", TEST_SEED)
idx = 0
monkeypatch.setattr(
pm.entry_manager, "get_totp_code", lambda *a, **k: "123456"
)
monkeypatch.setattr(
pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1
)
expected = "123456"
elif entry_type == "key_value":
idx = entry_mgr.add_key_value("API", "abc")
expected = "abc"
else: # managed_account
idx = entry_mgr.add_managed_account("acct", TEST_SEED)
expected = entry_mgr.get_managed_account_seed(idx, TEST_SEED)
pm.show_entry_details_by_index(idx)
out = capsys.readouterr().out
assert expected in out
if entry_type in {"ssh", "pgp"}:
assert extra in out
assert called == [True]
@pytest.mark.parametrize(
"entry_type", [EntryType.PASSWORD, EntryType.TOTP, EntryType.KEY_VALUE]
)
def test_show_entry_details_with_enum_type(monkeypatch, capsys, entry_type):
"""Entries storing an EntryType enum should display correctly."""
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
if entry_type == EntryType.PASSWORD:
idx = entry_mgr.add_entry("example.com", 8)
expect = "example.com"
elif entry_type == EntryType.TOTP:
entry_mgr.add_totp("Example", TEST_SEED)
idx = 0
monkeypatch.setattr(
pm.entry_manager, "get_totp_code", lambda *a, **k: "123456"
)
monkeypatch.setattr(
pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1
)
expect = "Label: Example"
else: # KEY_VALUE
idx = entry_mgr.add_key_value("API", "abc")
expect = "API"
data = entry_mgr._load_index(force_reload=True)
data["entries"][str(idx)]["type"] = entry_type
entry_mgr._save_index(data)
called = _detail_common(monkeypatch, pm)
pm.show_entry_details_by_index(idx)
out = capsys.readouterr().out
assert expect in out
assert called == [True]

View File

@@ -41,11 +41,11 @@ def test_search_entries_prompt_for_details(monkeypatch, capsys):
monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None)
monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b") monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b")
inputs = iter(["Example", "0", "n", ""]) inputs = iter(["Example", "0"])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
pm.handle_search_entries() pm.handle_search_entries()
out = capsys.readouterr().out out = capsys.readouterr().out
assert "0. Example" in out assert "0. Example" in out
assert "Retrieved 2FA Code" in out assert "Label: Example" in out
assert "123456" in out assert "Period: 30s" in out

View File

@@ -93,3 +93,46 @@ def test_show_private_key_qr(monkeypatch, capsys):
out = capsys.readouterr().out out = capsys.readouterr().out
assert called == [nsec] assert called == [nsec]
assert color_text(f"nsec: {nsec}", "deterministic") in out assert color_text(f"nsec: {nsec}", "deterministic") in out
def test_qr_menu_case_insensitive(monkeypatch):
"""QR menu should appear even if entry type is uppercase."""
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
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.parent_seed = TEST_SEED
pm.nostr_client = FakeNostrClient()
pm.fingerprint_dir = tmp_path
pm.is_dirty = False
pm.secret_mode_enabled = False
idx = entry_mgr.add_nostr_key("main")
npub, _ = entry_mgr.get_nostr_key_pair(idx, TEST_SEED)
# Modify index to use uppercase type/kind
data = enc_mgr.load_json_data(entry_mgr.index_file)
data["entries"][str(idx)]["type"] = "NOSTR"
data["entries"][str(idx)]["kind"] = "NOSTR"
enc_mgr.save_json_data(data, entry_mgr.index_file)
entry_mgr._index_cache = None
inputs = iter([str(idx), "q", "p", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
called = []
monkeypatch.setattr(
"password_manager.manager.TotpManager.print_qr_code",
lambda data: called.append(data),
)
pm.handle_retrieve_entry()
assert called == [f"nostr:{npub}"]

View File

@@ -0,0 +1,58 @@
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.manager import PasswordManager, EncryptionMode
from password_manager.config_manager import ConfigManager
import pytest
@pytest.mark.parametrize(
"adder,needs_confirm",
[
(lambda mgr: mgr.add_seed("seed", TEST_SEED), True),
(lambda mgr: mgr.add_pgp_key("pgp", TEST_SEED, user_id="test"), True),
(lambda mgr: mgr.add_ssh_key("ssh", TEST_SEED), True),
(lambda mgr: mgr.add_nostr_key("nostr"), False),
],
)
def test_pause_before_entry_actions(monkeypatch, adder, needs_confirm):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
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.parent_seed = TEST_SEED
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
index = adder(entry_mgr)
pause_calls = []
monkeypatch.setattr(
"password_manager.manager.pause", lambda *a, **k: pause_calls.append(True)
)
monkeypatch.setattr(pm, "_entry_actions_menu", lambda *a, **k: None)
monkeypatch.setattr("builtins.input", lambda *a, **k: str(index))
if needs_confirm:
monkeypatch.setattr(
"password_manager.manager.confirm_action", lambda *a, **k: True
)
pm.handle_retrieve_entry()
assert len(pause_calls) == 1