Merge pull request #276 from PR0M3TH3AN/beta

Beta
This commit is contained in:
thePR0M3TH3AN
2025-07-05 10:58:41 -04:00
committed by GitHub
24 changed files with 611 additions and 107 deletions

View File

@@ -169,7 +169,7 @@ seedpass totp "email"
# The code is printed and copied to your clipboard
# Sort or filter the list view
seedpass list --sort website
seedpass list --sort label
seedpass list --filter totp
# Use the **Settings** menu to configure an extra backup directory
@@ -185,7 +185,7 @@ The encrypted index file `seedpass_entries_db.json.enc` begins with `schema_vers
"schema_version": 2,
"entries": {
"0": {
"website": "example.com",
"label": "example.com",
"length": 8,
"type": "password",
"notes": ""
@@ -266,6 +266,9 @@ SeedPass supports storing more than just passwords and 2FA secrets. You can also
- **Seed Phrase** generate a BIP-39 mnemonic and keep it encrypted until needed.
- **PGP Key** derive an OpenPGP key pair from your master seed.
- **Nostr Key Pair** store the index used to derive an `npub`/`nsec` pair for Nostr clients.
When you retrieve one of these entries, SeedPass can display QR codes for the
keys. The `npub` is wrapped in the `nostr:` URI scheme so any client can scan
it, while the `nsec` QR is shown only after a security warning.
### Managing Multiple Seeds

View File

@@ -53,7 +53,7 @@ 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 --sort website` |
| List all entries | `list` | `-L` | `--list` | `seedpass list --sort label` |
| 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"`
@@ -179,11 +179,11 @@ seedpass delete --index 3
**Long Flag:** `--list`
**Description:**
Lists all password entries stored in the password manager. You can sort the output by index, website, or username and filter by entry type.
Lists all password entries stored in the password manager. You can sort the output by index, label, or username and filter by entry type.
**Usage Example:**
```bash
seedpass list --sort website
seedpass list --sort label
seedpass list --filter totp
```

View File

@@ -42,7 +42,7 @@ All entries belonging to a seed profile are summarized in an encrypted file name
"schema_version": 2,
"entries": {
"0": {
"website": "example.com",
"label": "example.com",
"length": 8,
"type": "password",
"notes": ""

View File

@@ -234,19 +234,40 @@ 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:
def print_matches(
password_manager: PasswordManager,
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
data = password_manager.entry_manager.retrieve_entry(idx)
etype = (
data.get("type", data.get("kind", EntryType.PASSWORD.value))
if data
else EntryType.PASSWORD.value
)
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"))
if etype == EntryType.TOTP.value:
print(colored(f" Label: {data.get('label', website)}", "cyan"))
print(colored(f" Derivation Index: {data.get('index', idx)}", "cyan"))
elif etype == EntryType.SEED.value:
print(colored(" Type: Seed Phrase", "cyan"))
elif etype == EntryType.SSH.value:
print(colored(" Type: SSH Key", "cyan"))
elif etype == EntryType.PGP.value:
print(colored(" Type: PGP Key", "cyan"))
elif etype == EntryType.NOSTR.value:
print(colored(" Type: Nostr Key", "cyan"))
else:
if website:
print(colored(f" Label: {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)
@@ -680,10 +701,11 @@ def display_menu(
1. Add Entry
2. Retrieve Entry
3. Search Entries
4. Modify an Existing Entry
5. 2FA Codes
6. Settings
7. Exit
4. List Entries
5. Modify an Existing Entry
6. 2FA Codes
7. Settings
8. Exit
"""
display_fn = getattr(password_manager, "display_stats", None)
if callable(display_fn):
@@ -708,7 +730,7 @@ def display_menu(
print(colored(menu, "cyan"))
try:
choice = timed_input(
"Enter your choice (1-7): ", inactivity_timeout
"Enter your choice (1-8): ", inactivity_timeout
).strip()
except TimeoutError:
print(colored("Session timed out. Vault locked.", "yellow"))
@@ -719,7 +741,7 @@ def display_menu(
if not choice:
print(
colored(
"No input detected. Please enter a number between 1 and 7.",
"No input detected. Please enter a number between 1 and 8.",
"yellow",
)
)
@@ -766,14 +788,17 @@ def display_menu(
password_manager.handle_search_entries()
elif choice == "4":
password_manager.update_activity()
password_manager.handle_modify_entry()
password_manager.handle_list_entries()
elif choice == "5":
password_manager.update_activity()
password_manager.handle_display_totp_codes()
password_manager.handle_modify_entry()
elif choice == "6":
password_manager.update_activity()
handle_settings(password_manager)
password_manager.handle_display_totp_codes()
elif choice == "7":
password_manager.update_activity()
handle_settings(password_manager)
elif choice == "8":
logging.info("Exiting the program.")
print(colored("Exiting the program.", "green"))
password_manager.nostr_client.close_client_pool()
@@ -831,7 +856,7 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "search":
matches = password_manager.entry_manager.search_entries(args.query)
if matches:
print_matches(matches)
print_matches(password_manager, matches)
else:
print(colored("No matching entries found.", "yellow"))
return 0
@@ -841,7 +866,7 @@ def main(argv: list[str] | None = None) -> int:
if not matches:
print(colored("No matching entries found.", "yellow"))
else:
print_matches(matches)
print_matches(password_manager, matches)
return 1
idx = matches[0][0]
entry = password_manager.entry_manager.retrieve_entry(idx)
@@ -858,7 +883,7 @@ def main(argv: list[str] | None = None) -> int:
if not matches:
print(colored("No matching entries found.", "yellow"))
else:
print_matches(matches)
print_matches(password_manager, matches)
return 1
idx = matches[0][0]
entry = password_manager.entry_manager.retrieve_entry(idx)

View File

@@ -65,6 +65,13 @@ class EntryManager:
if "kind" not in entry:
entry["kind"] = entry.get("type", EntryType.PASSWORD.value)
entry.setdefault("type", entry["kind"])
if "label" not in entry and "website" in entry:
entry["label"] = entry["website"]
if (
"website" in entry
and entry.get("type") == EntryType.PASSWORD.value
):
entry.pop("website", None)
logger.debug("Index loaded successfully.")
return data
except Exception as e:
@@ -106,7 +113,7 @@ class EntryManager:
def add_entry(
self,
website_name: str,
label: str,
length: int,
username: Optional[str] = None,
url: Optional[str] = None,
@@ -117,7 +124,7 @@ class EntryManager:
"""
Adds a new entry to the encrypted JSON index file.
:param website_name: The name of the website.
:param label: A label describing the entry (e.g. website name).
:param length: The desired length of the password.
:param username: (Optional) The username associated with the website.
:param url: (Optional) The URL of the website.
@@ -131,7 +138,7 @@ class EntryManager:
data.setdefault("entries", {})
data["entries"][str(index)] = {
"website": website_name,
"label": label,
"length": length,
"username": username if username else "",
"url": url if url else "",
@@ -222,7 +229,11 @@ class EntryManager:
raise
def add_ssh_key(
self, parent_seed: str, index: int | None = None, notes: str = ""
self,
label: str,
parent_seed: str,
index: int | None = None,
notes: str = "",
) -> int:
"""Add a new SSH key pair entry.
@@ -240,6 +251,7 @@ class EntryManager:
"type": EntryType.SSH.value,
"kind": EntryType.SSH.value,
"index": index,
"label": label,
"notes": notes,
}
self._save_index(data)
@@ -263,6 +275,7 @@ class EntryManager:
def add_pgp_key(
self,
label: str,
parent_seed: str,
index: int | None = None,
key_type: str = "ed25519",
@@ -280,6 +293,7 @@ class EntryManager:
"type": EntryType.PGP.value,
"kind": EntryType.PGP.value,
"index": index,
"label": label,
"key_type": key_type,
"user_id": user_id,
"notes": notes,
@@ -362,6 +376,7 @@ class EntryManager:
def add_seed(
self,
label: str,
parent_seed: str,
index: int | None = None,
words_num: int = 24,
@@ -378,6 +393,7 @@ class EntryManager:
"type": EntryType.SEED.value,
"kind": EntryType.SEED.value,
"index": index,
"label": label,
"words": words_num,
"notes": notes,
}
@@ -596,11 +612,11 @@ class EntryManager:
idx_str, entry = item
if sort_by == "index":
return int(idx_str)
if sort_by == "website":
return entry.get("website", "").lower()
if sort_by in {"website", "label"}:
return entry.get("label", entry.get("website", "")).lower()
if sort_by == "username":
return entry.get("username", "").lower()
raise ValueError("sort_by must be 'index', 'website', or 'username'")
raise ValueError("sort_by must be 'index', 'label', or 'username'")
sorted_items = sorted(entries_data.items(), key=sort_key)
@@ -616,19 +632,20 @@ class EntryManager:
entries: List[Tuple[int, str, Optional[str], Optional[str], bool]] = []
for idx, entry in filtered_items:
label = entry.get("label", entry.get("website", ""))
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
if etype == EntryType.TOTP.value:
entries.append((idx, entry.get("label", ""), None, None, False))
else:
if etype == EntryType.PASSWORD.value:
entries.append(
(
idx,
entry.get("website", ""),
label,
entry.get("username", ""),
entry.get("url", ""),
entry.get("blacklisted", False),
)
)
else:
entries.append((idx, label, None, None, False))
logger.debug(f"Total entries found: {len(entries)}")
for idx, entry in filtered_items:
@@ -644,8 +661,13 @@ class EntryManager:
"cyan",
)
)
else:
print(colored(f" Website: {entry.get('website', '')}", "cyan"))
elif etype == EntryType.PASSWORD.value:
print(
colored(
f" Label: {entry.get('label', entry.get('website', ''))}",
"cyan",
)
)
print(
colored(f" Username: {entry.get('username') or 'N/A'}", "cyan")
)
@@ -656,6 +678,13 @@ class EntryManager:
"cyan",
)
)
else:
print(colored(f" Label: {entry.get('label', '')}", "cyan"))
print(
colored(
f" Derivation Index: {entry.get('index', index)}", "cyan"
)
)
print("-" * 40)
return entries
@@ -680,16 +709,14 @@ class EntryManager:
for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])):
etype = entry.get("type", entry.get("kind", 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", "")
label = entry.get("label", entry.get("website", ""))
notes = entry.get("notes", "")
label_match = query_lower in label.lower()
notes_match = query_lower in notes.lower()
if etype == EntryType.PASSWORD.value:
username = entry.get("username", "")
url = entry.get("url", "")
notes = entry.get("notes", "")
custom_fields = entry.get("custom_fields", [])
custom_match = any(
query_lower in str(cf.get("label", "")).lower()
@@ -697,21 +724,24 @@ class EntryManager:
for cf in custom_fields
)
if (
query_lower in website.lower()
label_match
or query_lower in username.lower()
or query_lower in url.lower()
or query_lower in notes.lower()
or notes_match
or custom_match
):
results.append(
(
int(idx),
website,
label,
username,
url,
entry.get("blacklisted", False),
)
)
else:
if label_match or notes_match:
results.append((int(idx), label, None, None, False))
return results
@@ -829,7 +859,7 @@ class EntryManager:
for entry in entries:
index, website, username, url, blacklisted = entry
print(colored(f"Index: {index}", "cyan"))
print(colored(f" Website: {website}", "cyan"))
print(colored(f" Label: {website}", "cyan"))
print(colored(f" Username: {username or 'N/A'}", "cyan"))
print(colored(f" URL: {url or 'N/A'}", "cyan"))
print(
@@ -841,3 +871,29 @@ class EntryManager:
logger.error(f"Failed to list all entries: {e}", exc_info=True)
print(colored(f"Error: Failed to list all entries: {e}", "red"))
return
def get_entry_summaries(
self, filter_kind: str | None = None
) -> list[tuple[int, str, str]]:
"""Return a list of entry index, type, and display labels."""
try:
data = self.vault.load_index()
entries_data = data.get("entries", {})
summaries: list[tuple[int, str, str]] = []
for idx_str, entry in entries_data.items():
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
if filter_kind and etype != filter_kind:
continue
if etype == EntryType.PASSWORD.value:
label = entry.get("label", entry.get("website", ""))
else:
label = entry.get("label", etype)
summaries.append((int(idx_str), etype, label))
summaries.sort(key=lambda x: x[0])
return summaries
except Exception as e:
logger.error(f"Failed to get entry summaries: {e}", exc_info=True)
print(colored(f"Error: Failed to get entry summaries: {e}", "red"))
return []

View File

@@ -19,6 +19,7 @@ from typing import Optional
import shutil
import time
import select
import builtins
from termcolor import colored
from password_manager.encryption import EncryptionManager
@@ -859,9 +860,9 @@ class PasswordManager:
def handle_add_password(self) -> None:
try:
website_name = input("Enter the website name: ").strip()
website_name = input("Enter the label or website name: ").strip()
if not website_name:
print(colored("Error: Website name cannot be empty.", "red"))
print(colored("Error: Label cannot be empty.", "red"))
return
username = input("Enter the username (optional): ").strip()
@@ -1040,14 +1041,27 @@ class PasswordManager:
def handle_add_ssh_key(self) -> None:
"""Add an SSH key pair entry and display the derived keys."""
try:
label = input("Label: ").strip()
if not label:
print(colored("Error: Label cannot be empty.", "red"))
return
notes = input("Notes (optional): ").strip()
index = self.entry_manager.add_ssh_key(self.parent_seed, notes=notes)
index = self.entry_manager.add_ssh_key(label, self.parent_seed, notes=notes)
priv_pem, pub_pem = self.entry_manager.get_ssh_key_pair(
index, self.parent_seed
)
self.is_dirty = True
self.last_update = time.time()
if not confirm_action(
"WARNING: Displaying SSH keys reveals sensitive information. Continue? (Y/N): "
):
print(colored("SSH key display cancelled.", "yellow"))
return
print(colored(f"\n[+] SSH key entry added with ID {index}.\n", "green"))
if notes:
print(colored(f"Notes: {notes}", "cyan"))
print(colored("Public Key:", "cyan"))
print(pub_pem)
print(colored("Private Key:", "cyan"))
@@ -1066,6 +1080,10 @@ class PasswordManager:
def handle_add_seed(self) -> None:
"""Add a derived BIP-39 seed phrase entry."""
try:
label = input("Label: ").strip()
if not label:
print(colored("Error: Label cannot be empty.", "red"))
return
words_input = input("Word count (12 or 24, default 24): ").strip()
notes = input("Notes (optional): ").strip()
if words_input and words_input not in {"12", "24"}:
@@ -1073,14 +1091,27 @@ class PasswordManager:
return
words = int(words_input) if words_input else 24
index = self.entry_manager.add_seed(
self.parent_seed, words_num=words, notes=notes
label, self.parent_seed, words_num=words, notes=notes
)
phrase = self.entry_manager.get_seed_phrase(index, self.parent_seed)
self.is_dirty = True
self.last_update = time.time()
if not confirm_action(
"WARNING: Displaying the seed phrase reveals sensitive information. Continue? (Y/N): "
):
print(colored("Seed phrase display cancelled.", "yellow"))
return
print(colored(f"\n[+] Seed entry added with ID {index}.\n", "green"))
if notes:
print(colored(f"Notes: {notes}", "cyan"))
print(colored("Seed Phrase:", "cyan"))
print(colored(phrase, "yellow"))
if confirm_action("Show Compact Seed QR? (Y/N): "):
from password_manager.seedqr import encode_seedqr
TotpManager.print_qr_code(encode_seedqr(phrase))
try:
self.sync_vault()
except Exception as nostr_error:
@@ -1095,6 +1126,10 @@ class PasswordManager:
def handle_add_pgp(self) -> None:
"""Add a PGP key entry and display the generated key."""
try:
label = input("Label: ").strip()
if not label:
print(colored("Error: Label cannot be empty.", "red"))
return
key_type = (
input("Key type (ed25519 or rsa, default ed25519): ").strip().lower()
or "ed25519"
@@ -1102,6 +1137,7 @@ class PasswordManager:
user_id = input("User ID (optional): ").strip()
notes = input("Notes (optional): ").strip()
index = self.entry_manager.add_pgp_key(
label,
self.parent_seed,
key_type=key_type,
user_id=user_id,
@@ -1112,7 +1148,18 @@ class PasswordManager:
)
self.is_dirty = True
self.last_update = time.time()
if not confirm_action(
"WARNING: Displaying the PGP key reveals sensitive information. Continue? (Y/N): "
):
print(colored("PGP key display cancelled.", "yellow"))
return
print(colored(f"\n[+] PGP key entry added with ID {index}.\n", "green"))
if user_id:
print(colored(f"User ID: {user_id}", "cyan"))
if notes:
print(colored(f"Notes: {notes}", "cyan"))
print(colored(f"Fingerprint: {fingerprint}", "cyan"))
print(priv_key)
try:
@@ -1129,7 +1176,10 @@ class PasswordManager:
def handle_add_nostr_key(self) -> None:
"""Add a Nostr key entry and display the derived keys."""
try:
label = input("Label (optional): ").strip()
label = input("Label: ").strip()
if not label:
print(colored("Error: Label cannot be empty.", "red"))
return
notes = input("Notes (optional): ").strip()
index = self.entry_manager.add_nostr_key(label, notes=notes)
npub, nsec = self.entry_manager.get_nostr_key_pair(index, self.parent_seed)
@@ -1147,6 +1197,12 @@ class PasswordManager:
)
else:
print(colored(f"nsec: {nsec}", "cyan"))
if confirm_action("Show QR code for npub? (Y/N): "):
TotpManager.print_qr_code(f"nostr:{npub}")
if confirm_action(
"WARNING: Displaying the nsec QR reveals your private key. Continue? (Y/N): "
):
TotpManager.print_qr_code(nsec)
try:
self.sync_vault()
except Exception as nostr_error: # pragma: no cover - best effort
@@ -1158,6 +1214,26 @@ class PasswordManager:
logging.error(f"Error during Nostr key setup: {e}", exc_info=True)
print(colored(f"Error: Failed to add Nostr key: {e}", "red"))
def show_entry_details_by_index(self, index: int) -> None:
"""Display entry details using :meth:`handle_retrieve_entry` for the
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:
builtins.input = patched_input
self.handle_retrieve_entry()
finally:
builtins.input = original_input
def handle_retrieve_entry(self) -> None:
"""
Handles retrieving a password from the index by prompting the user for the index number
@@ -1232,10 +1308,23 @@ class PasswordManager:
return
if entry_type == EntryType.SSH.value:
notes = entry.get("notes", "")
label = entry.get("label", "")
if not confirm_action(
"WARNING: Displaying SSH keys reveals sensitive information. Continue? (Y/N): "
):
print(colored("SSH key display cancelled.", "yellow"))
return
try:
priv_pem, pub_pem = self.entry_manager.get_ssh_key_pair(
index, self.parent_seed
)
print(colored("\n[+] Retrieved SSH Key Pair:\n", "green"))
if label:
print(colored(f"Label: {label}", "cyan"))
if notes:
print(colored(f"Notes: {notes}", "cyan"))
print(colored("Public Key:", "cyan"))
print(pub_pem)
if self.secret_mode_enabled:
copy_to_clipboard(priv_pem, self.clipboard_clear_delay)
print(
@@ -1244,24 +1333,28 @@ class PasswordManager:
"green",
)
)
print(colored("Public Key:", "cyan"))
print(pub_pem)
else:
print(colored("\n[+] Retrieved SSH Key Pair:\n", "green"))
print(colored("Public Key:", "cyan"))
print(pub_pem)
print(colored("Private Key:", "cyan"))
print(priv_pem)
if notes:
print(colored(f"Notes: {notes}", "cyan"))
except Exception as e:
logging.error(f"Error deriving SSH key pair: {e}", exc_info=True)
print(colored(f"Error: Failed to derive SSH keys: {e}", "red"))
return
if entry_type == EntryType.SEED.value:
notes = entry.get("notes", "")
label = entry.get("label", "")
if not confirm_action(
"WARNING: Displaying the seed phrase reveals sensitive information. Continue? (Y/N): "
):
print(colored("Seed phrase display cancelled.", "yellow"))
return
try:
phrase = self.entry_manager.get_seed_phrase(index, self.parent_seed)
print(colored("\n[+] Retrieved Seed Phrase:\n", "green"))
if label:
print(colored(f"Label: {label}", "cyan"))
if notes:
print(colored(f"Notes: {notes}", "cyan"))
if self.secret_mode_enabled:
copy_to_clipboard(phrase, self.clipboard_clear_delay)
print(
@@ -1271,9 +1364,8 @@ class PasswordManager:
)
)
else:
print(colored("\n[+] Retrieved Seed Phrase:\n", "green"))
print(colored(phrase, "yellow"))
if confirm_action("Show SeedQR? (Y/N): "):
if confirm_action("Show Compact Seed QR? (Y/N): "):
from password_manager.seedqr import encode_seedqr
TotpManager.print_qr_code(encode_seedqr(phrase))
@@ -1300,10 +1392,22 @@ class PasswordManager:
return
if entry_type == EntryType.PGP.value:
notes = entry.get("notes", "")
label = entry.get("user_id", "")
if not confirm_action(
"WARNING: Displaying the PGP key reveals sensitive information. Continue? (Y/N): "
):
print(colored("PGP key display cancelled.", "yellow"))
return
try:
priv_key, fingerprint = self.entry_manager.get_pgp_key(
index, self.parent_seed
)
print(colored("\n[+] Retrieved PGP Key:\n", "green"))
if label:
print(colored(f"User ID: {label}", "cyan"))
if notes:
print(colored(f"Notes: {notes}", "cyan"))
print(colored(f"Fingerprint: {fingerprint}", "cyan"))
if self.secret_mode_enabled:
copy_to_clipboard(priv_key, self.clipboard_clear_delay)
print(
@@ -1313,11 +1417,7 @@ class PasswordManager:
)
)
else:
print(colored("\n[+] Retrieved PGP Key:\n", "green"))
print(colored(f"Fingerprint: {fingerprint}", "cyan"))
print(priv_key)
if notes:
print(colored(f"Notes: {notes}", "cyan"))
except Exception as e:
logging.error(f"Error deriving PGP key: {e}", exc_info=True)
print(colored(f"Error: Failed to derive PGP key: {e}", "red"))
@@ -1342,6 +1442,12 @@ class PasswordManager:
)
else:
print(colored(f"nsec: {nsec}", "cyan"))
if confirm_action("Show QR code for npub? (Y/N): "):
TotpManager.print_qr_code(f"nostr:{npub}")
if confirm_action(
"WARNING: Displaying the nsec QR reveals your private key. Continue? (Y/N): "
):
TotpManager.print_qr_code(nsec)
if notes:
print(colored(f"Notes: {notes}", "cyan"))
except Exception as e:
@@ -1682,21 +1788,134 @@ class PasswordManager:
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)
for match in results:
self.display_entry_details(match[0])
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 display_entry_details(self, index: int) -> None:
"""Print detailed information for a single entry."""
entry = self.entry_manager.retrieve_entry(index)
if not entry:
return
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
print(colored(f"Index: {index}", "cyan"))
if etype == EntryType.TOTP.value:
print(colored(f" Label: {entry.get('label', '')}", "cyan"))
print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan"))
print(
colored(
f" Period: {entry.get('period', 30)}s Digits: {entry.get('digits', 6)}",
"cyan",
)
)
notes = entry.get("notes", "")
if notes:
print(colored(f" Notes: {notes}", "cyan"))
elif etype == EntryType.SEED.value:
print(colored(" Type: Seed Phrase", "cyan"))
print(colored(f" Label: {entry.get('label', '')}", "cyan"))
print(colored(f" Words: {entry.get('words', 24)}", "cyan"))
print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan"))
notes = entry.get("notes", "")
if notes:
print(colored(f" Notes: {notes}", "cyan"))
elif etype == EntryType.SSH.value:
print(colored(" Type: SSH Key", "cyan"))
print(colored(f" Label: {entry.get('label', '')}", "cyan"))
print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan"))
notes = entry.get("notes", "")
if notes:
print(colored(f" Notes: {notes}", "cyan"))
elif etype == EntryType.PGP.value:
print(colored(" Type: PGP Key", "cyan"))
print(colored(f" Label: {entry.get('label', '')}", "cyan"))
print(colored(f" Key Type: {entry.get('key_type', 'ed25519')}", "cyan"))
uid = entry.get("user_id", "")
if uid:
print(colored(f" User ID: {uid}", "cyan"))
print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan"))
notes = entry.get("notes", "")
if notes:
print(colored(f" Notes: {notes}", "cyan"))
elif etype == EntryType.NOSTR.value:
print(colored(" Type: Nostr Key", "cyan"))
print(colored(f" Label: {entry.get('label', '')}", "cyan"))
print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan"))
notes = entry.get("notes", "")
if notes:
print(colored(f" Notes: {notes}", "cyan"))
else:
website = entry.get("label", entry.get("website", ""))
username = entry.get("username", "")
url = entry.get("url", "")
blacklisted = entry.get("blacklisted", False)
print(colored(f" Label: {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)
def handle_list_entries(self) -> None:
"""List entries and optionally show details."""
try:
while True:
print("\nList Entries:")
print("1. All")
print("2. Passwords")
print("3. 2FA (TOTP)")
print("4. SSH Key")
print("5. Seed Phrase")
print("6. Nostr Key Pair")
print("7. PGP")
print("8. Back")
choice = input("Select entry type: ").strip()
if choice == "1":
filter_kind = None
elif choice == "2":
filter_kind = EntryType.PASSWORD.value
elif choice == "3":
filter_kind = EntryType.TOTP.value
elif choice == "4":
filter_kind = EntryType.SSH.value
elif choice == "5":
filter_kind = EntryType.SEED.value
elif choice == "6":
filter_kind = EntryType.NOSTR.value
elif choice == "7":
filter_kind = EntryType.PGP.value
elif choice == "8":
return
else:
print(colored("Invalid choice.", "red"))
continue
summaries = self.entry_manager.get_entry_summaries(filter_kind)
if not summaries:
continue
while True:
print(colored("\n[+] Entries:\n", "green"))
for idx, etype, label in summaries:
if filter_kind is None:
display_type = etype.capitalize()
print(colored(f"{idx}. {display_type} - {label}", "cyan"))
else:
print(colored(f"{idx}. {label}", "cyan"))
idx_input = input(
"Enter index to view details or press Enter to go back: "
).strip()
if not idx_input:
break
if not idx_input.isdigit():
print(colored("Invalid index.", "red"))
continue
self.show_entry_details_by_index(int(idx_input))
except Exception as e:
logging.error(f"Failed to list entries: {e}", exc_info=True)
print(colored(f"Error: Failed to list entries: {e}", "red"))
def delete_entry(self) -> None:
"""Deletes an entry from the password index."""
try:

View File

@@ -33,6 +33,10 @@ def _v1_to_v2(data: dict) -> dict:
for k, v in passwords.items():
v.setdefault("type", "password")
v.setdefault("notes", "")
if "label" not in v and "website" in v:
v["label"] = v["website"]
if v.get("type") == "password" and "website" in v:
v.pop("website", None)
entries[k] = v
data["entries"] = entries
data["schema_version"] = 2
@@ -46,6 +50,10 @@ def _v2_to_v3(data: dict) -> dict:
for entry in entries.values():
entry.setdefault("custom_fields", [])
entry.setdefault("origin", "")
if entry.get("type", "password") == "password":
if "label" not in entry and "website" in entry:
entry["label"] = entry["website"]
entry.pop("website", None)
data["schema_version"] = 3
return data

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 *_: "7")
monkeypatch.setattr(main, "timed_input", lambda *_: "8")
with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=0.1)

View File

@@ -25,7 +25,7 @@ def test_backup_restore_workflow(monkeypatch):
"schema_version": 3,
"entries": {
"0": {
"website": "a",
"label": "a",
"length": 10,
"type": "password",
"kind": "password",
@@ -48,7 +48,7 @@ def test_backup_restore_workflow(monkeypatch):
"schema_version": 3,
"entries": {
"0": {
"website": "b",
"label": "b",
"length": 12,
"type": "password",
"kind": "password",

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", "7"])
inputs = iter(["", "abc", "8"])
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", "7"])
inputs = iter(["9", "8"])
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", "8", "7", "7"])
inputs = iter(["1", "8", "7", "8"])
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 *_: "7")
monkeypatch.setattr(main, "timed_input", lambda *_: "8")
with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1)
out = capsys.readouterr().out

View File

@@ -30,7 +30,7 @@ def test_add_and_retrieve_entry():
entry = entry_mgr.retrieve_entry(index)
assert entry == {
"website": "example.com",
"label": "example.com",
"length": 12,
"username": "user",
"url": "",
@@ -69,9 +69,9 @@ def test_round_trip_entry_types(method, expected_type):
index = 0
else:
if method == "add_ssh_key":
index = entry_mgr.add_ssh_key(TEST_SEED)
index = entry_mgr.add_ssh_key("ssh", TEST_SEED)
elif method == "add_seed":
index = entry_mgr.add_seed(TEST_SEED)
index = entry_mgr.add_seed("seed", TEST_SEED)
else:
index = getattr(entry_mgr, method)()

View File

@@ -36,7 +36,7 @@ def test_inactivity_triggers_lock(monkeypatch):
unlock_vault=unlock_vault,
)
monkeypatch.setattr(main, "timed_input", lambda *_: "7")
monkeypatch.setattr(main, "timed_input", lambda *_: "8")
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(), "7"])
responses = iter([TimeoutError(), "8"])
def fake_input(*_args, **_kwargs):
val = next(responses)

View File

@@ -34,7 +34,7 @@ def test_index_export_import_round_trip():
"schema_version": 3,
"entries": {
"0": {
"website": "example",
"label": "example",
"type": "password",
"notes": "",
"custom_fields": [],
@@ -52,7 +52,7 @@ def test_index_export_import_round_trip():
"schema_version": 3,
"entries": {
"0": {
"website": "changed",
"label": "changed",
"type": "password",
"notes": "",
"custom_fields": [],

View File

@@ -0,0 +1,84 @@
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 test_handle_list_entries(monkeypatch, capsys):
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
entry_mgr.add_totp("Example", TEST_SEED)
entry_mgr.add_entry("example.com", 12)
inputs = iter(["1", ""]) # list all, then exit
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
pm.handle_list_entries()
out = capsys.readouterr().out
assert "Example" in out
assert "example.com" in out
def test_list_entries_show_details(monkeypatch, capsys):
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 = SimpleNamespace()
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
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: 1
)
monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None)
monkeypatch.setattr(sys.stdin, "readline", lambda *a, **k: "b\n")
monkeypatch.setattr(
"password_manager.manager.select.select",
lambda *a, **k: ([sys.stdin], [], []),
)
inputs = iter(["1", "0"])
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
pm.handle_list_entries()
out = capsys.readouterr().out
assert "Retrieved 2FA Code" in out
assert "123456" in out

View File

@@ -0,0 +1,41 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
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
def test_search_entries_shows_totp_details(monkeypatch, capsys):
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
pm.secret_mode_enabled = False
entry_mgr.add_totp("Example", TEST_SEED)
monkeypatch.setattr("builtins.input", lambda *a, **k: "Example")
pm.handle_search_entries()
out = capsys.readouterr().out
assert "Label: Example" in out
assert "Derivation Index" in out

View File

@@ -30,7 +30,7 @@ def _make_pm(calls):
def test_menu_totp_option(monkeypatch):
calls = []
pm = _make_pm(calls)
inputs = iter(["5", "7"])
inputs = iter(["6", "8"])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
monkeypatch.setattr(main, "handle_settings", lambda *_: None)
with pytest.raises(SystemExit):
@@ -41,7 +41,7 @@ def test_menu_totp_option(monkeypatch):
def test_menu_settings_option(monkeypatch):
calls = []
pm = _make_pm(calls)
inputs = iter(["6", "7"])
inputs = iter(["7", "8"])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
monkeypatch.setattr(main, "handle_settings", lambda *_: calls.append("settings"))
with pytest.raises(SystemExit):

View File

@@ -30,7 +30,7 @@ def _make_pm(called):
def test_menu_search_option(monkeypatch):
called = []
pm = _make_pm(called)
inputs = iter(["3", "7"])
inputs = iter(["3", "8"])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
monkeypatch.setattr("builtins.input", lambda *_: "query")
with pytest.raises(SystemExit):

View File

@@ -20,7 +20,7 @@ def test_migrate_v0_to_v3(tmp_path: Path):
data = vault.load_index()
assert data["schema_version"] == LATEST_VERSION
expected_entry = {
"website": "a",
"label": "a",
"length": 8,
"type": "password",
"notes": "",
@@ -37,7 +37,7 @@ def test_migrate_v1_to_v3(tmp_path: Path):
data = vault.load_index()
assert data["schema_version"] == LATEST_VERSION
expected_entry = {
"website": "b",
"label": "b",
"length": 10,
"type": "password",
"notes": "",
@@ -59,7 +59,7 @@ def test_migrate_v2_to_v3(tmp_path: Path):
data = vault.load_index()
assert data["schema_version"] == LATEST_VERSION
expected_entry = {
"website": "c",
"label": "c",
"length": 5,
"type": "password",
"notes": "",

View File

@@ -58,7 +58,7 @@ def test_nostr_index_size_limits(pytestconfig: pytest.Config):
if max_entries is not None and entry_count >= max_entries:
break
entry_mgr.add_entry(
website_name=f"site-{entry_count + 1}",
label=f"site-{entry_count + 1}",
length=12,
username="u" * size,
url="https://example.com/" + "a" * size,

View File

@@ -0,0 +1,60 @@
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, TotpManager
from password_manager.config_manager import ConfigManager
class FakeNostrClient:
def __init__(self, *args, **kwargs):
self.published = []
def publish_snapshot(self, data: bytes):
self.published.append(data)
return None, "abcd"
def test_show_qr_for_nostr_keys(monkeypatch):
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)
monkeypatch.setattr("builtins.input", lambda *a, **k: str(idx))
responses = iter([True, False])
monkeypatch.setattr(
"password_manager.manager.confirm_action",
lambda *_a, **_k: next(responses),
)
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

@@ -19,7 +19,9 @@ def test_pgp_key_determinism():
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
idx = entry_mgr.add_pgp_key(TEST_SEED, key_type="ed25519", user_id="Test")
idx = entry_mgr.add_pgp_key(
"pgp", TEST_SEED, key_type="ed25519", user_id="Test"
)
key1, fp1 = entry_mgr.get_pgp_key(idx, TEST_SEED)
key2, fp2 = entry_mgr.get_pgp_key(idx, TEST_SEED)

View File

@@ -23,8 +23,8 @@ def test_seed_phrase_determinism():
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
idx_12 = entry_mgr.add_seed(TEST_SEED, words_num=12)
idx_24 = entry_mgr.add_seed(TEST_SEED, words_num=24)
idx_12 = entry_mgr.add_seed("seed12", TEST_SEED, words_num=12)
idx_24 = entry_mgr.add_seed("seed24", TEST_SEED, words_num=24)
phrase12_a = entry_mgr.get_seed_phrase(idx_12, TEST_SEED)
phrase12_b = entry_mgr.get_seed_phrase(idx_12, TEST_SEED)

View File

@@ -20,9 +20,15 @@ def test_add_and_retrieve_ssh_key_pair():
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
index = entry_mgr.add_ssh_key(TEST_SEED)
index = entry_mgr.add_ssh_key("ssh", TEST_SEED)
entry = entry_mgr.retrieve_entry(index)
assert entry == {"type": "ssh", "kind": "ssh", "index": index, "notes": ""}
assert entry == {
"type": "ssh",
"kind": "ssh",
"index": index,
"label": "ssh",
"notes": "",
}
priv1, pub1 = entry_mgr.get_ssh_key_pair(index, TEST_SEED)
priv2, pub2 = entry_mgr.get_ssh_key_pair(index, TEST_SEED)

View File

@@ -21,7 +21,7 @@ def test_ssh_private_key_corresponds_to_public():
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
idx = entry_mgr.add_ssh_key(TEST_SEED)
idx = entry_mgr.add_ssh_key("ssh", TEST_SEED)
priv_pem, pub_pem = entry_mgr.get_ssh_key_pair(idx, TEST_SEED)
priv_key = serialization.load_pem_private_key(