diff --git a/README.md b/README.md index aa88e8d..6767c7c 100644 --- a/README.md +++ b/README.md @@ -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 50 KB 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 ` > Managed Account > `. 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 diff --git a/docs/advanced_cli.md b/docs/advanced_cli.md index 6694314..fa6298b 100644 --- a/docs/advanced_cli.md +++ b/docs/advanced_cli.md @@ -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//accounts/`. + +Managed account seeds are always **12 words** long. + +**Usage Example:** +```bash +seedpass add-managed --label "Account" +``` + +When loaded, the breadcrumb shows ` > Managed Account > `. 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` diff --git a/docs/json_entries.md b/docs/json_entries.md index 263f0ea..0d9c374 100644 --- a/docs/json_entries.md +++ b/docs/json_entries.md @@ -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": "" + }, + "timestamp": "2024-04-27T12:41:56Z" +} +``` + +Managed accounts store a child seed derived from the parent profile. The entry is saved under +`.seedpass//accounts/` where `` is the managed account's +fingerprint. When loaded, the CLI displays a breadcrumb like ` > Managed Account > `. +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. diff --git a/landing/index.html b/landing/index.html index fe8958c..9d4d776 100644 --- a/landing/index.html +++ b/landing/index.html @@ -151,6 +151,7 @@ flowchart TD
  • Export your 2FA codes to an encrypted file
  • Optional external backup location
  • Auto-lock after inactivity
  • +
  • Derive nested managed account seeds
  • Secret Mode copies passwords to your clipboard
  • diff --git a/src/main.py b/src/main.py index d9afad2..3767a37 100644 --- a/src/main.py +++ b/src/main.py @@ -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() diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 5a2149b..d67bda8 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -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( diff --git a/src/password_manager/entry_types.py b/src/password_manager/entry_types.py index 24aa9e7..da5bd15 100644 --- a/src/password_manager/entry_types.py +++ b/src/password_manager/entry_types.py @@ -14,3 +14,4 @@ class EntryType(str, Enum): PGP = "pgp" NOSTR = "nostr" KEY_VALUE = "key_value" + MANAGED_ACCOUNT = "managed_account" diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 4698272..cb502f1 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -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( diff --git a/src/tests/test_archive_from_retrieve.py b/src/tests/test_archive_from_retrieve.py index 2383503..779d3f3 100644 --- a/src/tests/test_archive_from_retrieve.py +++ b/src/tests/test_archive_from_retrieve.py @@ -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() diff --git a/src/tests/test_cli_invalid_input.py b/src/tests/test_cli_invalid_input.py index cfdf2bb..589f162 100644 --- a/src/tests/test_cli_invalid_input.py +++ b/src/tests/test_cli_invalid_input.py @@ -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): diff --git a/src/tests/test_custom_fields_display.py b/src/tests/test_custom_fields_display.py index 279e19a..04e2d45 100644 --- a/src/tests/test_custom_fields_display.py +++ b/src/tests/test_custom_fields_display.py @@ -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() diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index ed2630e..ea9328e 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -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): diff --git a/src/tests/test_managed_account.py b/src/tests/test_managed_account.py new file mode 100644 index 0000000..b62d814 --- /dev/null +++ b/src/tests/test_managed_account.py @@ -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 == [] diff --git a/src/tests/test_managed_account_entry.py b/src/tests/test_managed_account_entry.py new file mode 100644 index 0000000..d9d6cef --- /dev/null +++ b/src/tests/test_managed_account_entry.py @@ -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 == [] diff --git a/src/tests/test_manager_list_entries.py b/src/tests/test_manager_list_entries.py index abd62dd..46d1f6d 100644 --- a/src/tests/test_manager_list_entries.py +++ b/src/tests/test_manager_list_entries.py @@ -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 diff --git a/src/tests/test_manager_retrieve_totp.py b/src/tests/test_manager_retrieve_totp.py index ae0f8d2..2d300ad 100644 --- a/src/tests/test_manager_retrieve_totp.py +++ b/src/tests/test_manager_retrieve_totp.py @@ -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( diff --git a/src/tests/test_manager_workflow.py b/src/tests/test_manager_workflow.py index 5a95cd1..c12f39e 100644 --- a/src/tests/test_manager_workflow.py +++ b/src/tests/test_manager_workflow.py @@ -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 diff --git a/src/tests/test_nostr_qr.py b/src/tests/test_nostr_qr.py index ccb81e9..aaa594f 100644 --- a/src/tests/test_nostr_qr.py +++ b/src/tests/test_nostr_qr.py @@ -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( diff --git a/src/tests/test_secret_mode.py b/src/tests/test_secret_mode.py index e11f7a2..3a524d7 100644 --- a/src/tests/test_secret_mode.py +++ b/src/tests/test_secret_mode.py @@ -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( diff --git a/src/utils/terminal_utils.py b/src/utils/terminal_utils.py index 00f1975..8c856ed 100644 --- a/src/utils/terminal_utils.py +++ b/src/utils/terminal_utils.py @@ -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():