Merge pull request #395 from PR0M3TH3AN/beta

Beta
This commit is contained in:
thePR0M3TH3AN
2025-07-08 11:42:15 -04:00
committed by GitHub
20 changed files with 808 additions and 61 deletions

View File

@@ -45,6 +45,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
- **Chunked Snapshots:** Encrypted vaults are compressed and split into 50KB chunks published as `kind 30071` events with a `kind 30070` manifest and `kind 30072` deltas.
- **Automatic Checksum Generation:** The script generates and verifies a SHA-256 checksum to detect tampering.
- **Multiple Seed Profiles:** Manage separate seed profiles and switch between them seamlessly.
- **Nested Managed Account Seeds:** SeedPass can derive nested managed account seeds.
- **Interactive TUI:** Navigate through menus to add, retrieve, and modify entries as well as configure Nostr settings.
- **SeedPass 2FA:** Generate TOTP codes with a real-time countdown progress bar.
- **2FA Secret Issuance & Import:** Derive new TOTP secrets from your seed or import existing `otpauth://` URIs.
@@ -239,8 +240,16 @@ python src/main.py
Enter your choice (1-7) or press Enter to exit:
```
When choosing **Add Entry**, you can now select **Password**, **2FA (TOTP)**,
**SSH Key**, **Seed Phrase**, or **PGP Key**.
When choosing **Add Entry**, you can now select from:
- **Password**
- **2FA (TOTP)**
- **SSH Key**
- **Seed Phrase**
- **Nostr Key Pair**
- **PGP Key**
- **Key/Value**
- **Managed Account**
### Adding a 2FA Entry
@@ -279,6 +288,7 @@ SeedPass supports storing more than just passwords and 2FA secrets. You can also
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.
- **Key/Value** store a simple key and value for miscellaneous secrets or configuration data.
- **Managed Account** derive a child seed under the current profile. Loading a managed account switches to a nested profile and the header shows `<parent_fp> > Managed Account > <child_fp>`. Press Enter on the main menu to return to the parent profile.
The table below summarizes the extra fields stored for each entry type. Every
entry includes a `label`, while only password entries track a `url`.
@@ -292,6 +302,7 @@ entry includes a `label`, while only password entries track a `url`.
| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes` |
| Nostr Key Pair| `index`, `archived`, optional `notes` |
| Key/Value | `value`, `archived`, optional `notes`, optional `custom_fields` |
| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes` |
### Managing Multiple Seeds

View File

@@ -39,9 +39,10 @@ The **Advanced CLI Commands** document provides an in-depth guide to the various
- [23. Add Notes to an Entry](#23-add-notes-to-an-entry)
- [24. Add Tags to an Entry](#24-add-tags-to-an-entry)
- [25. Add Key/Value Entry](#25-add-keyvalue-entry)
- [26. Search by Tag or Title](#26-search-by-tag-or-title)
- [27. Automatically Post Deltas to Nostr After Edit](#27-automatically-post-deltas-to-nostr-after-edit)
- [28. Initial Setup Prompt for Seed Generation/Import](#28-initial-setup-prompt-for-seed-generationimport)
- [26. Add Managed Account](#26-add-managed-account)
- [27. Search by Tag or Title](#26-search-by-tag-or-title)
- [28. Automatically Post Deltas to Nostr After Edit](#27-automatically-post-deltas-to-nostr-after-edit)
- [29. Initial Setup Prompt for Seed Generation/Import](#28-initial-setup-prompt-for-seed-generationimport)
3. [Notes on New CLI Commands](#notes-on-new-cli-commands)
---
@@ -80,6 +81,7 @@ The following table provides a quick reference to all available advanced CLI com
| Add Notes to an Entry | `add-notes` | `-AN` | `--add-notes` | `seedpass add-notes --index 3 --notes "This is a secured account"` |
| Add Tags to an Entry | `add-tags` | `-AT` | `--add-tags` | `seedpass add-tags --index 3 --tags "personal,finance"` |
| Add Key/Value entry | `add-kv` | `-KV` | `--add-kv` | `seedpass add-kv --label "API" --value "secret"`
| Add Managed Account | `add-managed` | `-AM` | `--add-managed` | `seedpass add-managed --label "Account"`
| Search by Tag or Title | `search-by` | `-SB` | `--search-by` | `seedpass search-by --tag "work"` or `seedpass search-by --title "GitHub"` |
| Automatically Post Deltas After Edit | `auto-post` | `-AP` | `--auto-post` | `seedpass auto-post --enable` or `seedpass auto-post --disable` |
| Initial Setup Prompt for Seed Generation/Import | `setup` | `-ST` | `--setup` | `seedpass setup` |
@@ -601,7 +603,25 @@ seedpass add-kv --label "API" --value "secret" --notes "Service token"
---
### 26. Search by Tag or Title
### 26. Add Managed Account
**Command:** `add-managed`
**Short Flag:** `-AM`
**Long Flag:** `--add-managed`
**Description:**
Creates a managed account derived from the current seed profile. The child profile is stored in `.seedpass/<parent_fp>/accounts/<child_fp>`.
Managed account seeds are always **12 words** long.
**Usage Example:**
```bash
seedpass add-managed --label "Account"
```
When loaded, the breadcrumb shows `<parent_fp> > Managed Account > <child_fp>`. Press Enter on the main menu to return to the parent profile.
### 27. Search by Tag or Title
**Command:** `search-by`
**Short Flag:** `-SB`
@@ -622,7 +642,7 @@ seedpass search-by --title "GitHub"
---
### 27. Automatically Post Deltas to Nostr After Edit
### 28. Automatically Post Deltas to Nostr After Edit
**Command:** `auto-post`
**Short Flag:** `-AP`
@@ -643,7 +663,7 @@ seedpass auto-post --disable
---
### 28. Initial Setup Prompt for Seed Generation/Import
### 29. Initial Setup Prompt for Seed Generation/Import
**Command:** `setup`
**Short Flag:** `-ST`

View File

@@ -95,6 +95,8 @@ Each entry is stored within `seedpass_entries_db.json.enc` under the `entries` d
- **origin** (`string`, optional): Source identifier for imported data.
- **value** (`string`, optional): For `key_value` entries, stores the secret value.
- **index** (`integer`, optional): BIP-85 derivation index for entries that derive material from a seed.
- **word_count** (`integer`, managed_account only): Number of words in the child seed. Managed accounts always use `12`.
- **fingerprint** (`string`, managed_account only): Identifier of the child profile, used for its directory name.
Example:
```json
@@ -252,6 +254,26 @@ Each entry is stored within `seedpass_entries_db.json.enc` under the `entries` d
}
```
#### 8. Managed Account
```json
{
"entry_num": 7,
"fingerprint": "a1b2c3d4",
"kind": "managed_account",
"data": {
"account": "alice@example.com",
"password": "<encrypted_password>"
},
"timestamp": "2024-04-27T12:41:56Z"
}
```
Managed accounts store a child seed derived from the parent profile. The entry is saved under
`.seedpass/<parent_fp>/accounts/<child_fp>` where `<child_fp>` is the managed account's
fingerprint. When loaded, the CLI displays a breadcrumb like `<parent_fp> > Managed Account > <child_fp>`.
Press **Enter** on the main menu to exit back to the parent profile.
The `key` field is purely descriptive, while `value` holds the sensitive string
such as an API token. Notes and custom fields may also be included alongside the
standard metadata.

View File

@@ -151,6 +151,7 @@ flowchart TD
<li><i class="fas fa-file-export" aria-hidden="true"></i> Export your 2FA codes to an encrypted file</li>
<li><i class="fas fa-folder-open" aria-hidden="true"></i> Optional external backup location</li>
<li><i class="fas fa-lock" aria-hidden="true"></i> Auto-lock after inactivity</li>
<li><i class="fas fa-users-cog" aria-hidden="true"></i> Derive nested managed account seeds</li>
<li><i class="fas fa-user-secret" aria-hidden="true"></i> Secret Mode copies passwords to your clipboard</li>
</ul>
</div>

View File

@@ -587,9 +587,16 @@ def handle_toggle_secret_mode(pm: PasswordManager) -> None:
def handle_profiles_menu(password_manager: PasswordManager) -> None:
"""Submenu for managing seed profiles."""
while True:
fp, parent_fp, child_fp = getattr(
password_manager,
"header_fingerprint_args",
(getattr(password_manager, "current_fingerprint", None), None, None),
)
clear_and_print_fingerprint(
getattr(password_manager, "current_fingerprint", None),
fp,
"Main Menu > Settings > Profiles",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
print(color_text("\nProfiles:", "menu"))
print(color_text("1. Switch Seed Profile", "menu"))
@@ -626,9 +633,16 @@ def handle_nostr_menu(password_manager: PasswordManager) -> None:
return
while True:
fp, parent_fp, child_fp = getattr(
password_manager,
"header_fingerprint_args",
(getattr(password_manager, "current_fingerprint", None), None, None),
)
clear_and_print_fingerprint(
getattr(password_manager, "current_fingerprint", None),
fp,
"Main Menu > Settings > Nostr",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
print(color_text("\nNostr Settings:", "menu"))
print(color_text("1. Backup to Nostr", "menu"))
@@ -663,9 +677,16 @@ def handle_nostr_menu(password_manager: PasswordManager) -> None:
def handle_settings(password_manager: PasswordManager) -> None:
"""Interactive settings menu with submenus for profiles and Nostr."""
while True:
fp, parent_fp, child_fp = getattr(
password_manager,
"header_fingerprint_args",
(getattr(password_manager, "current_fingerprint", None), None, None),
)
clear_and_print_fingerprint(
getattr(password_manager, "current_fingerprint", None),
fp,
"Main Menu > Settings",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
print(color_text("\nSettings:", "menu"))
print(color_text("1. Profiles", "menu"))
@@ -757,9 +778,16 @@ def display_menu(
display_fn()
pause()
while True:
fp, parent_fp, child_fp = getattr(
password_manager,
"header_fingerprint_args",
(getattr(password_manager, "current_fingerprint", None), None, None),
)
clear_and_print_fingerprint(
getattr(password_manager, "current_fingerprint", None),
fp,
"Main Menu",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
if time.time() - password_manager.last_activity > inactivity_timeout:
print(colored("Session timed out. Vault locked.", "yellow"))
@@ -790,15 +818,29 @@ def display_menu(
continue
password_manager.update_activity()
if not choice:
if getattr(password_manager, "profile_stack", []):
password_manager.exit_managed_account()
continue
logging.info("Exiting the program.")
print(colored("Exiting the program.", "green"))
password_manager.nostr_client.close_client_pool()
sys.exit(0)
if choice == "1":
while True:
fp, parent_fp, child_fp = getattr(
password_manager,
"header_fingerprint_args",
(
getattr(password_manager, "current_fingerprint", None),
None,
None,
),
)
clear_and_print_fingerprint(
getattr(password_manager, "current_fingerprint", None),
fp,
"Main Menu > Add Entry",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
print(color_text("\nAdd Entry:", "menu"))
print(color_text("1. Password", "menu"))
@@ -808,6 +850,7 @@ def display_menu(
print(color_text("5. Nostr Key Pair", "menu"))
print(color_text("6. PGP Key", "menu"))
print(color_text("7. Key/Value", "menu"))
print(color_text("8. Managed Account", "menu"))
sub_choice = input(
"Select entry type or press Enter to go back: "
).strip()
@@ -833,6 +876,9 @@ def display_menu(
elif sub_choice == "7":
password_manager.handle_add_key_value()
break
elif sub_choice == "8":
password_manager.handle_add_managed_account()
break
elif not sub_choice:
break
else:
@@ -840,9 +886,16 @@ def display_menu(
elif choice == "2":
password_manager.update_activity()
password_manager.handle_retrieve_entry()
fp, parent_fp, child_fp = getattr(
password_manager,
"header_fingerprint_args",
(getattr(password_manager, "current_fingerprint", None), None, None),
)
clear_and_print_fingerprint(
getattr(password_manager, "current_fingerprint", None),
fp,
"Main Menu",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
elif choice == "3":
password_manager.update_activity()

View File

@@ -27,6 +27,7 @@ from termcolor import colored
from password_manager.migrations import LATEST_VERSION
from password_manager.entry_types import EntryType
from password_manager.totp import TotpManager
from utils.fingerprint import generate_fingerprint
from password_manager.vault import Vault
from password_manager.backup import BackupManager
@@ -474,6 +475,79 @@ class EntryManager:
seed_index = int(entry.get("index", index))
return derive_seed_phrase(bip85, seed_index, words)
def add_managed_account(
self,
label: str,
parent_seed: str,
*,
index: int | None = None,
notes: str = "",
archived: bool = False,
) -> int:
"""Add a new managed account seed entry.
Managed accounts always use a 12-word seed phrase.
"""
if index is None:
index = self.get_next_index()
from password_manager.password_generation import derive_seed_phrase
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
seed_bytes = Bip39SeedGenerator(parent_seed).Generate()
bip85 = BIP85(seed_bytes)
word_count = 12
seed_phrase = derive_seed_phrase(bip85, index, word_count)
fingerprint = generate_fingerprint(seed_phrase)
account_dir = self.fingerprint_dir / "accounts" / fingerprint
account_dir.mkdir(parents=True, exist_ok=True)
data = self.vault.load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {
"type": EntryType.MANAGED_ACCOUNT.value,
"kind": EntryType.MANAGED_ACCOUNT.value,
"index": index,
"label": label,
"word_count": word_count,
"notes": notes,
"fingerprint": fingerprint,
"archived": archived,
}
self._save_index(data)
self.update_checksum()
self.backup_manager.create_backup()
return index
def get_managed_account_seed(self, index: int, parent_seed: str) -> str:
"""Return the seed phrase for a managed account entry."""
entry = self.retrieve_entry(index)
etype = entry.get("type") if entry else None
kind = entry.get("kind") if entry else None
if not entry or (
etype != EntryType.MANAGED_ACCOUNT.value
and kind != EntryType.MANAGED_ACCOUNT.value
):
raise ValueError("Entry is not a managed account entry")
from password_manager.password_generation import derive_seed_phrase
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
seed_bytes = Bip39SeedGenerator(parent_seed).Generate()
bip85 = BIP85(seed_bytes)
words = int(entry.get("word_count", 12))
seed_index = int(entry.get("index", index))
return derive_seed_phrase(bip85, seed_index, words)
def get_totp_code(
self, index: int, parent_seed: str | None = None, timestamp: int | None = None
) -> str:
@@ -533,7 +607,11 @@ class EntryManager:
if entry:
etype = entry.get("type", entry.get("kind"))
if etype in (EntryType.PASSWORD.value, EntryType.KEY_VALUE.value):
if etype in (
EntryType.PASSWORD.value,
EntryType.KEY_VALUE.value,
EntryType.MANAGED_ACCOUNT.value,
):
entry.setdefault("custom_fields", [])
logger.debug(f"Retrieved entry at index {index}: {entry}")
return entry
@@ -620,7 +698,10 @@ class EntryManager:
if url is not None:
entry["url"] = url
logger.debug(f"Updated URL to '{url}' for index {index}.")
elif entry_type == EntryType.KEY_VALUE.value:
elif entry_type in (
EntryType.KEY_VALUE.value,
EntryType.MANAGED_ACCOUNT.value,
):
if value is not None:
entry["value"] = value
logger.debug(f"Updated value for index {index}.")
@@ -837,7 +918,7 @@ class EntryManager:
entry.get("archived", entry.get("blacklisted", False)),
)
)
elif etype == EntryType.KEY_VALUE.value:
elif etype in (EntryType.KEY_VALUE.value, EntryType.MANAGED_ACCOUNT.value):
value_field = str(entry.get("value", ""))
custom_fields = entry.get("custom_fields", [])
custom_match = any(

View File

@@ -14,3 +14,4 @@ class EntryType(str, Enum):
PGP = "pgp"
NOSTR = "nostr"
KEY_VALUE = "key_value"
MANAGED_ACCOUNT = "managed_account"

View File

@@ -56,7 +56,9 @@ from utils.terminal_utils import (
clear_screen,
pause,
clear_and_print_fingerprint,
clear_and_print_profile_chain,
)
from utils.fingerprint import generate_fingerprint
from constants import MIN_HEALTHY_RELAYS
from constants import (
@@ -124,6 +126,7 @@ class PasswordManager:
self.inactivity_timeout: float = INACTIVITY_TIMEOUT
self.secret_mode_enabled: bool = False
self.clipboard_clear_delay: int = 45
self.profile_stack: list[tuple[str, Path, str]] = []
# Initialize the fingerprint manager first
self.initialize_fingerprint_manager()
@@ -167,6 +170,31 @@ class PasswordManager:
else:
self._parent_seed_secret = InMemorySecret(value.encode("utf-8"))
@property
def header_fingerprint(self) -> str | None:
"""Return the fingerprint chain for header display."""
if not getattr(self, "current_fingerprint", None):
return None
if not self.profile_stack:
return self.current_fingerprint
chain = [fp for fp, _path, _seed in self.profile_stack] + [
self.current_fingerprint
]
header = chain[0]
for fp in chain[1:]:
header += f" > Managed Account > {fp}"
return header
@property
def header_fingerprint_args(self) -> tuple[str | None, str | None, str | None]:
"""Return fingerprint parameters for header display."""
if not getattr(self, "current_fingerprint", None):
return (None, None, None)
if not self.profile_stack:
return (self.current_fingerprint, None, None)
parent_fp = self.profile_stack[-1][0]
return (None, parent_fp, self.current_fingerprint)
def update_activity(self) -> None:
"""Record the current time as the last user activity."""
self.last_activity = time.time()
@@ -459,6 +487,54 @@ class PasswordManager:
print(colored(f"Error: Failed to switch seed profiles: {e}", "red"))
return False # Return False to indicate failure
def load_managed_account(self, index: int) -> None:
"""Load a managed account derived from the current seed profile."""
if not self.entry_manager or not self.parent_seed:
raise ValueError("Manager not initialized")
seed = self.entry_manager.get_managed_account_seed(index, self.parent_seed)
managed_fp = generate_fingerprint(seed)
account_dir = self.fingerprint_dir / "accounts" / managed_fp
account_dir.mkdir(parents=True, exist_ok=True)
self.profile_stack.append(
(self.current_fingerprint, self.fingerprint_dir, self.parent_seed)
)
self.current_fingerprint = managed_fp
self.fingerprint_dir = account_dir
self.parent_seed = seed
key = derive_index_key(seed)
self.encryption_manager = EncryptionManager(key, account_dir)
self.vault = Vault(self.encryption_manager, account_dir)
self.initialize_bip85()
self.initialize_managers()
self.locked = False
self.update_activity()
self.sync_index_from_nostr_if_missing()
def exit_managed_account(self) -> None:
"""Return to the parent seed profile if one is on the stack."""
if not self.profile_stack:
return
fp, path, seed = self.profile_stack.pop()
self.current_fingerprint = fp
self.fingerprint_dir = path
self.parent_seed = seed
key = derive_index_key(seed)
self.encryption_manager = EncryptionManager(key, path)
self.vault = Vault(self.encryption_manager, path)
self.initialize_bip85()
self.initialize_managers()
self.locked = False
self.update_activity()
self.sync_index_from_nostr()
def handle_existing_seed(self) -> None:
"""
Handles the scenario where an existing parent seed file is found.
@@ -890,9 +966,12 @@ class PasswordManager:
def handle_add_password(self) -> None:
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > Add Entry > Password",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
website_name = input("Enter the label or website name: ").strip()
if not website_name:
@@ -980,9 +1059,12 @@ class PasswordManager:
def handle_add_totp(self) -> None:
"""Add a TOTP entry either derived from the seed or imported."""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > Add Entry > 2FA (TOTP)",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
while True:
print("\nAdd TOTP:")
@@ -1087,9 +1169,12 @@ class PasswordManager:
def handle_add_ssh_key(self) -> None:
"""Add an SSH key pair entry and display the derived keys."""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > Add Entry > SSH Key",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
label = input("Label: ").strip()
if not label:
@@ -1132,9 +1217,12 @@ class PasswordManager:
def handle_add_seed(self) -> None:
"""Add a derived BIP-39 seed phrase entry."""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > Add Entry > Seed Phrase",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
label = input("Label: ").strip()
if not label:
@@ -1191,9 +1279,12 @@ class PasswordManager:
def handle_add_pgp(self) -> None:
"""Add a PGP key entry and display the generated key."""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > Add Entry > PGP Key",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
label = input("Label: ").strip()
if not label:
@@ -1247,9 +1338,12 @@ class PasswordManager:
def handle_add_nostr_key(self) -> None:
"""Add a Nostr key entry and display the derived keys."""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > Add Entry > Nostr Key Pair",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
label = input("Label: ").strip()
if not label:
@@ -1294,9 +1388,12 @@ class PasswordManager:
def handle_add_key_value(self) -> None:
"""Add a generic key/value entry."""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > Add Entry > Key/Value",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
label = input("Label: ").strip()
if not label:
@@ -1353,6 +1450,61 @@ class PasswordManager:
print(colored(f"Error: Failed to add key/value entry: {e}", "red"))
pause()
def handle_add_managed_account(self) -> None:
"""Add a managed account seed entry."""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
fp,
"Main Menu > Add Entry > Managed Account",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
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_managed_account(
label, self.parent_seed, notes=notes
)
seed = self.entry_manager.get_managed_account_seed(index, self.parent_seed)
self.is_dirty = True
self.last_update = time.time()
print(
colored(
f"\n[+] Managed account '{label}' added with ID {index}.\n",
"green",
)
)
if confirm_action("Reveal seed now? (y/N): "):
if self.secret_mode_enabled:
copy_to_clipboard(seed, self.clipboard_clear_delay)
print(
colored(
f"[+] Seed copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.",
"green",
)
)
else:
print(color_text(seed, "deterministic"))
if confirm_action("Show Compact Seed QR? (Y/N): "):
from password_manager.seedqr import encode_seedqr
TotpManager.print_qr_code(encode_seedqr(seed))
try:
self.sync_vault()
except Exception as nostr_error: # pragma: no cover - best effort
logging.error(
f"Failed to post updated index to Nostr: {nostr_error}",
exc_info=True,
)
pause()
except Exception as e:
logging.error(f"Error during managed account setup: {e}", exc_info=True)
print(colored(f"Error: Failed to add managed account: {e}", "red"))
pause()
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."""
@@ -1390,15 +1542,105 @@ class PasswordManager:
self.is_dirty = True
self.last_update = time.time()
def _entry_actions_menu(self, index: int, entry: dict) -> None:
"""Provide actions for a retrieved entry."""
while True:
archived = entry.get("archived", entry.get("blacklisted", False))
print(colored("\n[+] Entry Actions:", "green"))
if archived:
print(colored("U. Unarchive", "cyan"))
else:
print(colored("A. Archive", "cyan"))
print(colored("N. Add Note", "cyan"))
print(colored("C. Add Custom Field", "cyan"))
print(colored("H. Add Hidden Field", "cyan"))
print(colored("E. Edit", "cyan"))
choice = (
input("Select an action or press Enter to return: ").strip().lower()
)
if not choice:
break
if choice == "a" and not archived:
self.entry_manager.archive_entry(index)
self.is_dirty = True
self.last_update = time.time()
elif choice == "u" and archived:
self.entry_manager.restore_entry(index)
self.is_dirty = True
self.last_update = time.time()
elif choice == "n":
note = input("Enter note: ").strip()
if note:
notes = entry.get("notes", "")
notes = f"{notes}\n{note}" if notes else note
self.entry_manager.modify_entry(index, notes=notes)
self.is_dirty = True
self.last_update = time.time()
elif choice in {"c", "h"}:
label = input(" Field label: ").strip()
if not label:
print(colored("Field label cannot be empty.", "red"))
else:
value = input(" Field value: ").strip()
hidden = choice == "h"
custom_fields = entry.get("custom_fields", [])
custom_fields.append(
{"label": label, "value": value, "is_hidden": hidden}
)
self.entry_manager.modify_entry(index, custom_fields=custom_fields)
self.is_dirty = True
self.last_update = time.time()
elif choice == "e":
self._entry_edit_menu(index, entry)
else:
print(colored("Invalid choice.", "red"))
entry = self.entry_manager.retrieve_entry(index) or entry
def _entry_edit_menu(self, index: int, entry: dict) -> None:
"""Sub-menu for editing common entry fields."""
entry_type = entry.get("type", EntryType.PASSWORD.value)
while True:
print(colored("\n[+] Edit Menu:", "green"))
print(colored("L. Edit Label", "cyan"))
if entry_type == EntryType.PASSWORD.value:
print(colored("U. Edit Username", "cyan"))
print(colored("R. Edit URL", "cyan"))
choice = input("Select option or press Enter to go back: ").strip().lower()
if not choice:
break
if choice == "l":
new_label = input("New label: ").strip()
if new_label:
self.entry_manager.modify_entry(index, label=new_label)
self.is_dirty = True
self.last_update = time.time()
elif entry_type == EntryType.PASSWORD.value and choice == "u":
new_username = input("New username: ").strip()
self.entry_manager.modify_entry(index, username=new_username)
self.is_dirty = True
self.last_update = time.time()
elif entry_type == EntryType.PASSWORD.value and choice == "r":
new_url = input("New URL: ").strip()
self.entry_manager.modify_entry(index, url=new_url)
self.is_dirty = True
self.last_update = time.time()
else:
print(colored("Invalid choice.", "red"))
entry = self.entry_manager.retrieve_entry(index) or entry
def handle_retrieve_entry(self) -> None:
"""
Handles retrieving a password from the index by prompting the user for the index number
and displaying the corresponding password and associated details.
"""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > Retrieve Entry",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
index_input = input(
"Enter the index number of the entry to retrieve: "
@@ -1470,7 +1712,7 @@ class PasswordManager:
except Exception as e:
logging.error(f"Error generating TOTP code: {e}", exc_info=True)
print(colored(f"Error: Failed to generate TOTP code: {e}", "red"))
self._prompt_toggle_archive(entry, index)
self._entry_actions_menu(index, entry)
pause()
return
if entry_type == EntryType.SSH.value:
@@ -1506,7 +1748,7 @@ class PasswordManager:
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"))
self._prompt_toggle_archive(entry, index)
self._entry_actions_menu(index, entry)
pause()
return
if entry_type == EntryType.SEED.value:
@@ -1557,7 +1799,7 @@ class PasswordManager:
except Exception as e:
logging.error(f"Error deriving seed phrase: {e}", exc_info=True)
print(colored(f"Error: Failed to derive seed phrase: {e}", "red"))
self._prompt_toggle_archive(entry, index)
self._entry_actions_menu(index, entry)
pause()
return
if entry_type == EntryType.PGP.value:
@@ -1591,7 +1833,7 @@ class PasswordManager:
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"))
self._prompt_toggle_archive(entry, index)
self._entry_actions_menu(index, entry)
pause()
return
if entry_type == EntryType.NOSTR.value:
@@ -1625,7 +1867,7 @@ class PasswordManager:
except Exception as e:
logging.error(f"Error deriving Nostr keys: {e}", exc_info=True)
print(colored(f"Error: Failed to derive Nostr keys: {e}", "red"))
self._prompt_toggle_archive(entry, index)
self._entry_actions_menu(index, entry)
pause()
return
@@ -1682,11 +1924,61 @@ class PasswordManager:
)
else:
print(colored(f" {f_label}: {f_value}", "cyan"))
self._prompt_toggle_archive(entry, index)
self._entry_actions_menu(index, entry)
pause()
return
if entry_type == EntryType.MANAGED_ACCOUNT.value:
label = entry.get("label", "")
notes = entry.get("notes", "")
archived = entry.get("archived", False)
fingerprint = entry.get("fingerprint", "")
print(colored(f"Managed account '{label}'.", "cyan"))
if notes:
print(colored(f"Notes: {notes}", "cyan"))
if fingerprint:
print(colored(f"Fingerprint: {fingerprint}", "cyan"))
print(
colored(
f"Archived Status: {'Archived' if archived else 'Active'}",
"cyan",
)
)
action = (
input(
"Enter 'r' to reveal seed, 'l' to load account, or press Enter to go back: "
)
.strip()
.lower()
)
if action == "r":
seed = self.entry_manager.get_managed_account_seed(
index, self.parent_seed
)
if self.secret_mode_enabled:
copy_to_clipboard(seed, self.clipboard_clear_delay)
print(
colored(
f"[+] Seed phrase copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.",
"green",
)
)
else:
print(color_text(seed, "deterministic"))
if confirm_action("Show Compact Seed QR? (Y/N): "):
from password_manager.seedqr import encode_seedqr
TotpManager.print_qr_code(encode_seedqr(seed))
self._entry_actions_menu(index, entry)
pause()
return
if action == "l":
self.load_managed_account(index)
return
self._entry_actions_menu(index, entry)
pause()
return
website_name = entry.get("website")
website_name = entry.get("label", entry.get("website"))
length = entry.get("length")
username = entry.get("username")
url = entry.get("url")
@@ -1770,7 +2062,7 @@ class PasswordManager:
print(colored(f" {label}: {value}", "cyan"))
else:
print(colored("Error: Failed to retrieve the password.", "red"))
self._prompt_toggle_archive(entry, index)
self._entry_actions_menu(index, entry)
pause()
except Exception as e:
logging.error(f"Error during password retrieval: {e}", exc_info=True)
@@ -1783,9 +2075,12 @@ class PasswordManager:
and new details to update.
"""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > Modify Entry",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
index_input = input(
"Enter the index number of the entry to modify: "
@@ -1904,7 +2199,10 @@ class PasswordManager:
digits=new_digits,
custom_fields=custom_fields,
)
elif entry_type == EntryType.KEY_VALUE.value:
elif entry_type in (
EntryType.KEY_VALUE.value,
EntryType.MANAGED_ACCOUNT.value,
):
label = entry.get("label", "")
value = entry.get("value", "")
blacklisted = entry.get("archived", False)
@@ -2108,9 +2406,12 @@ class PasswordManager:
def handle_search_entries(self) -> None:
"""Prompt for a query, list matches and optionally show details."""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > Search Entries",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
query = input("Enter search string: ").strip()
if not query:
@@ -2125,9 +2426,12 @@ class PasswordManager:
return
while True:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > Search Entries",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
print(colored("\n[+] Search Results:\n", "green"))
for idx, label, username, _url, _b in results:
@@ -2238,9 +2542,12 @@ class PasswordManager:
"""List entries and optionally show details."""
try:
while True:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > List Entries",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
print(color_text("\nList Entries:", "menu"))
print(color_text("1. All", "menu"))
@@ -2251,6 +2558,7 @@ class PasswordManager:
print(color_text("6. Nostr Key Pair", "menu"))
print(color_text("7. PGP", "menu"))
print(color_text("8. Key/Value", "menu"))
print(color_text("9. Managed Account", "menu"))
choice = input("Select entry type or press Enter to go back: ").strip()
if choice == "1":
filter_kind = None
@@ -2268,6 +2576,8 @@ class PasswordManager:
filter_kind = EntryType.PGP.value
elif choice == "8":
filter_kind = EntryType.KEY_VALUE.value
elif choice == "9":
filter_kind = EntryType.MANAGED_ACCOUNT.value
elif not choice:
return
else:
@@ -2280,9 +2590,12 @@ class PasswordManager:
if not summaries:
continue
while True:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > List Entries",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
print(colored("\n[+] Entries:\n", "green"))
for idx, etype, label in summaries:
@@ -2369,9 +2682,12 @@ class PasswordManager:
pause()
return
while True:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > Archived Entries",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
print(colored("\n[+] Archived Entries:\n", "green"))
for idx, label, _username, _url, _ in archived:
@@ -2396,7 +2712,7 @@ class PasswordManager:
.lower()
)
if action == "v":
self.display_entry_details(entry_index)
self.show_entry_details_by_index(entry_index)
pause()
elif action == "r":
self.entry_manager.restore_entry(entry_index)
@@ -2423,9 +2739,12 @@ class PasswordManager:
def handle_display_totp_codes(self) -> None:
"""Display all stored TOTP codes with a countdown progress bar."""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > 2FA Codes",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
data = self.entry_manager.vault.load_index()
entries = data.get("entries", {})
@@ -2446,9 +2765,12 @@ class PasswordManager:
totp_list.sort(key=lambda t: t[0].lower())
print(colored("Press Enter to return to the menu.", "cyan"))
while True:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > 2FA Codes",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
print(colored("Press Enter to return to the menu.", "cyan"))
generated = [t for t in totp_list if not t[3]]
@@ -2504,9 +2826,12 @@ class PasswordManager:
Handles verifying the script's checksum against the stored checksum to ensure integrity.
"""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > Settings > Verify Script Checksum",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
current_checksum = calculate_checksum(__file__)
try:
@@ -2542,9 +2867,12 @@ class PasswordManager:
print(colored("Operation cancelled.", "yellow"))
return
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > Settings > Generate Script Checksum",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
script_path = Path(__file__).resolve()
if update_checksum_file(str(script_path), str(SCRIPT_CHECKSUM_FILE)):
@@ -2655,9 +2983,12 @@ class PasswordManager:
) -> Path | None:
"""Export the current database to an encrypted portable file."""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > Settings > Export database",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
path = export_backup(
self.vault,
@@ -2675,9 +3006,12 @@ class PasswordManager:
def handle_import_database(self, src: Path) -> None:
"""Import a portable database file, replacing the current index."""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > Settings > Import database",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
import_backup(
self.vault,
@@ -2693,9 +3027,12 @@ class PasswordManager:
def handle_export_totp_codes(self) -> Path | None:
"""Export all 2FA codes to a JSON file for other authenticator apps."""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > Settings > Export 2FA codes",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
data = self.entry_manager.vault.load_index()
entries = data.get("entries", {})
@@ -2756,9 +3093,12 @@ class PasswordManager:
Handles the backup and reveal of the parent seed.
"""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
fp,
"Main Menu > Settings > Backup Parent Seed",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
print(colored("\n=== Backup Parent Seed ===", "yellow"))
print(

View File

@@ -40,7 +40,7 @@ def test_archive_entry_from_retrieve(monkeypatch):
index = entry_mgr.add_entry("example.com", 8)
inputs = iter([str(index), "y"])
inputs = iter([str(index), "a", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
pm.handle_retrieve_entry()
@@ -72,7 +72,7 @@ def test_restore_entry_from_retrieve(monkeypatch):
index = entry_mgr.add_entry("example.com", 8)
entry_mgr.archive_entry(index)
inputs = iter([str(index), "y"])
inputs = iter([str(index), "u", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
pm.handle_retrieve_entry()

View File

@@ -40,6 +40,7 @@ def _make_pm(called, locked=None):
nostr_client=SimpleNamespace(close_client_pool=lambda: None),
handle_add_password=add,
handle_add_totp=lambda: None,
handle_add_managed_account=lambda: None,
handle_retrieve_entry=retrieve,
handle_modify_entry=modify,
update_activity=update,
@@ -76,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", "", ""])
inputs = iter(["1", "9", "", ""])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
with pytest.raises(SystemExit):

View File

@@ -42,7 +42,7 @@ def test_retrieve_entry_shows_custom_fields(monkeypatch, capsys):
],
)
inputs = iter(["0", "y", "", "n"])
inputs = iter(["0", "y", "", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
pm.handle_retrieve_entry()

View File

@@ -54,6 +54,7 @@ def test_add_and_retrieve_entry():
("add_ssh_key", "ssh"),
("add_seed", "seed"),
("add_key_value", "key_value"),
("add_managed_account", "managed_account"),
],
)
def test_round_trip_entry_types(method, expected_type):
@@ -75,6 +76,8 @@ def test_round_trip_entry_types(method, expected_type):
index = entry_mgr.add_ssh_key("ssh", TEST_SEED)
elif method == "add_seed":
index = entry_mgr.add_seed("seed", TEST_SEED)
elif method == "add_managed_account":
index = entry_mgr.add_managed_account("acct", TEST_SEED)
else:
index = getattr(entry_mgr, method)()
@@ -113,6 +116,7 @@ def test_legacy_entry_defaults_to_password():
("add_nostr_key", ("nostr",)),
("add_seed", ("seed", TEST_SEED)),
("add_key_value", ("label", "val")),
("add_managed_account", ("acct", TEST_SEED)),
],
)
def test_add_default_archived_false(method, args):

View File

@@ -0,0 +1,87 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
from utils.fingerprint import generate_fingerprint
import password_manager.manager as manager_module
from password_manager.manager import EncryptionMode
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager
from password_manager.backup import BackupManager
from password_manager.config_manager import ConfigManager
def setup_entry_manager(tmp_path: Path) -> EntryManager:
vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg = ConfigManager(vault, tmp_path)
backup = BackupManager(tmp_path, cfg)
return EntryManager(vault, backup)
def test_add_managed_account_fields_and_dir():
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
mgr = setup_entry_manager(tmp_path)
idx = mgr.add_managed_account("acct", TEST_SEED)
entry = mgr.retrieve_entry(idx)
assert entry["type"] == "managed_account"
assert entry["kind"] == "managed_account"
assert entry["index"] == idx
assert entry["label"] == "acct"
assert entry["word_count"] == 12
assert entry["archived"] is False
fp = entry.get("fingerprint")
assert fp
assert (tmp_path / "accounts" / fp).exists()
seed = mgr.get_managed_account_seed(idx, TEST_SEED)
assert generate_fingerprint(seed) == fp
def test_load_and_exit_managed_account(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)
idx = entry_mgr.add_managed_account("acct", TEST_SEED)
seed = entry_mgr.get_managed_account_seed(idx, TEST_SEED)
fp = generate_fingerprint(seed)
acct_dir = tmp_path / "accounts" / fp
pm = manager_module.PasswordManager.__new__(manager_module.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.config_manager = cfg_mgr
pm.parent_seed = TEST_SEED
pm.current_fingerprint = "rootfp"
pm.fingerprint_dir = tmp_path
pm.profile_stack = []
monkeypatch.setattr(pm, "initialize_bip85", lambda: None)
monkeypatch.setattr(pm, "initialize_managers", lambda: None)
monkeypatch.setattr(pm, "sync_index_from_nostr_if_missing", lambda: None)
monkeypatch.setattr(pm, "sync_index_from_nostr", lambda: None)
monkeypatch.setattr(pm, "update_activity", lambda: None)
pm.load_managed_account(idx)
assert pm.current_fingerprint == fp
assert pm.fingerprint_dir == acct_dir
assert pm.profile_stack[-1][0] == "rootfp"
assert pm.profile_stack[-1][1] == tmp_path
pm.exit_managed_account()
assert pm.current_fingerprint == "rootfp"
assert pm.fingerprint_dir == tmp_path
assert pm.profile_stack == []

View File

@@ -0,0 +1,96 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
from utils.fingerprint import generate_fingerprint
import password_manager.manager as manager_module
from password_manager.manager import EncryptionMode
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager
from password_manager.backup import BackupManager
from password_manager.config_manager import ConfigManager
from password_manager.password_generation import derive_seed_phrase
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
def setup_mgr(tmp_path: Path) -> EntryManager:
vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg = ConfigManager(vault, tmp_path)
backup = BackupManager(tmp_path, cfg)
return EntryManager(vault, backup)
def test_add_and_get_managed_account_seed():
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
mgr = setup_mgr(tmp_path)
idx = mgr.add_managed_account("acct", TEST_SEED)
entry = mgr.retrieve_entry(idx)
assert entry["type"] == "managed_account"
assert entry["kind"] == "managed_account"
assert entry["index"] == idx
assert entry["label"] == "acct"
assert entry["word_count"] == 12
assert entry["archived"] is False
fp = entry.get("fingerprint")
assert fp
assert (tmp_path / "accounts" / fp).exists()
phrase_a = mgr.get_managed_account_seed(idx, TEST_SEED)
phrase_b = mgr.get_managed_account_seed(idx, TEST_SEED)
assert phrase_a == phrase_b
seed_bytes = Bip39SeedGenerator(TEST_SEED).Generate()
bip85 = BIP85(seed_bytes)
expected = derive_seed_phrase(bip85, idx, 12)
assert phrase_a == expected
assert generate_fingerprint(phrase_a) == fp
def test_load_and_exit_managed_account(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)
idx = entry_mgr.add_managed_account("acct", TEST_SEED)
seed = entry_mgr.get_managed_account_seed(idx, TEST_SEED)
fp = generate_fingerprint(seed)
acct_dir = tmp_path / "accounts" / fp
pm = manager_module.PasswordManager.__new__(manager_module.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.config_manager = cfg_mgr
pm.parent_seed = TEST_SEED
pm.current_fingerprint = "rootfp"
pm.fingerprint_dir = tmp_path
pm.profile_stack = []
monkeypatch.setattr(pm, "initialize_bip85", lambda: None)
monkeypatch.setattr(pm, "initialize_managers", lambda: None)
monkeypatch.setattr(pm, "sync_index_from_nostr_if_missing", lambda: None)
monkeypatch.setattr(pm, "sync_index_from_nostr", lambda: None)
monkeypatch.setattr(pm, "update_activity", lambda: None)
pm.load_managed_account(idx)
assert pm.current_fingerprint == fp
assert pm.fingerprint_dir == acct_dir
assert pm.profile_stack[-1][0] == "rootfp"
assert pm.profile_stack[-1][1] == tmp_path
pm.exit_managed_account()
assert pm.current_fingerprint == "rootfp"
assert pm.fingerprint_dir == tmp_path
assert pm.profile_stack == []

View File

@@ -34,6 +34,7 @@ def test_handle_list_entries(monkeypatch, capsys):
entry_mgr.add_totp("Example", TEST_SEED)
entry_mgr.add_entry("example.com", 12)
entry_mgr.add_key_value("API", "abc123")
entry_mgr.add_managed_account("acct", TEST_SEED)
inputs = iter(["1", ""]) # list all, then exit
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
@@ -43,6 +44,7 @@ def test_handle_list_entries(monkeypatch, capsys):
assert "Example" in out
assert "example.com" in out
assert "API" in out
assert "acct" in out
def test_list_entries_show_details(monkeypatch, capsys):
@@ -66,6 +68,7 @@ def test_list_entries_show_details(monkeypatch, capsys):
entry_mgr.add_totp("Example", TEST_SEED)
entry_mgr.add_key_value("API", "val")
entry_mgr.add_managed_account("acct", TEST_SEED)
monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456")
monkeypatch.setattr(
@@ -85,3 +88,4 @@ def test_list_entries_show_details(monkeypatch, capsys):
assert "Retrieved 2FA Code" in out
assert "123456" in out
assert "API" in out
assert "acct" in out

View File

@@ -43,7 +43,7 @@ def test_handle_retrieve_totp_entry(monkeypatch, capsys):
entry_mgr.add_totp("Example", TEST_SEED)
inputs = iter(["0", "n"])
inputs = iter(["0", "n", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456")
monkeypatch.setattr(

View File

@@ -57,7 +57,7 @@ def test_manager_workflow(monkeypatch):
"n", # add custom field
"", # length (default)
"0", # retrieve index
"n", # archive entry prompt
"", # no action in entry menu
"0", # modify index
"", # new label
"user", # new username

View File

@@ -44,7 +44,7 @@ def test_show_qr_for_nostr_keys(monkeypatch):
idx = entry_mgr.add_nostr_key("main")
npub, _ = entry_mgr.get_nostr_key_pair(idx, TEST_SEED)
inputs = iter([str(idx), "n", ""])
inputs = iter([str(idx), "n", "", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
responses = iter([True, False])
monkeypatch.setattr(

View File

@@ -41,7 +41,7 @@ def test_password_retrieve_secret_mode(monkeypatch, capsys):
pm, entry_mgr = setup_pm(tmp)
entry_mgr.add_entry("example", 8)
inputs = iter(["0", "n"])
inputs = iter(["0", "n", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
called = []
monkeypatch.setattr(
@@ -90,7 +90,7 @@ def test_password_retrieve_no_secret_mode(monkeypatch, capsys):
pm.secret_mode_enabled = False
entry_mgr.add_entry("example", 8)
inputs = iter(["0", "n"])
inputs = iter(["0", "n", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
called = []
monkeypatch.setattr(

View File

@@ -12,17 +12,43 @@ def clear_screen() -> None:
def clear_and_print_fingerprint(
fingerprint: str | None, breadcrumb: str | None = None
fingerprint: str | None = None,
breadcrumb: str | None = None,
parent_fingerprint: str | None = None,
child_fingerprint: str | None = None,
) -> None:
"""Clear the screen and optionally display the current fingerprint and path."""
clear_screen()
if fingerprint:
header = f"Seed Profile: {fingerprint}"
header_fp = None
if parent_fingerprint and child_fingerprint:
header_fp = f"{parent_fingerprint} > Managed Account > {child_fingerprint}"
elif fingerprint:
header_fp = fingerprint
elif parent_fingerprint or child_fingerprint:
header_fp = parent_fingerprint or child_fingerprint
if header_fp:
header = f"Seed Profile: {header_fp}"
if breadcrumb:
header += f" > {breadcrumb}"
print(colored(header, "green"))
def clear_and_print_profile_chain(
fingerprints: list[str] | None, breadcrumb: str | None = None
) -> None:
"""Clear the screen and display a chain of fingerprints."""
clear_screen()
if not fingerprints:
return
chain = fingerprints[0]
for fp in fingerprints[1:]:
chain += f" > Managed Account > {fp}"
header = f"Seed Profile: {chain}"
if breadcrumb:
header += f" > {breadcrumb}"
print(colored(header, "green"))
def pause(message: str = "Press Enter to continue...") -> None:
"""Wait for the user to press Enter before proceeding."""
if not sys.stdin or not sys.stdin.isatty():