mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-10 08:19:23 +00:00
@@ -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
|
||||
|
@@ -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
|
||||
```
|
||||
|
||||
|
@@ -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": ""
|
||||
|
65
src/main.py
65
src/main.py
@@ -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)
|
||||
|
@@ -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 []
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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)
|
||||
|
@@ -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",
|
||||
|
@@ -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
|
||||
|
@@ -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)()
|
||||
|
||||
|
@@ -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)
|
||||
|
@@ -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": [],
|
||||
|
84
src/tests/test_manager_list_entries.py
Normal file
84
src/tests/test_manager_list_entries.py
Normal 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
|
41
src/tests/test_manager_search_display.py
Normal file
41
src/tests/test_manager_search_display.py
Normal 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
|
@@ -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):
|
||||
|
@@ -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):
|
||||
|
@@ -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": "",
|
||||
|
@@ -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,
|
||||
|
60
src/tests/test_nostr_qr.py
Normal file
60
src/tests/test_nostr_qr.py
Normal 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}"]
|
@@ -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)
|
||||
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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(
|
||||
|
Reference in New Issue
Block a user