mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-10 00:09:04 +00:00
15
README.md
15
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 `<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
|
||||
|
@@ -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`
|
||||
|
@@ -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.
|
||||
|
@@ -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>
|
||||
|
65
src/main.py
65
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()
|
||||
|
@@ -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(
|
||||
|
@@ -14,3 +14,4 @@ class EntryType(str, Enum):
|
||||
PGP = "pgp"
|
||||
NOSTR = "nostr"
|
||||
KEY_VALUE = "key_value"
|
||||
MANAGED_ACCOUNT = "managed_account"
|
||||
|
@@ -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(
|
||||
|
@@ -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()
|
||||
|
@@ -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):
|
||||
|
@@ -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()
|
||||
|
@@ -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):
|
||||
|
87
src/tests/test_managed_account.py
Normal file
87
src/tests/test_managed_account.py
Normal 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 == []
|
96
src/tests/test_managed_account_entry.py
Normal file
96
src/tests/test_managed_account_entry.py
Normal 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 == []
|
@@ -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
|
||||
|
@@ -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(
|
||||
|
@@ -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
|
||||
|
@@ -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(
|
||||
|
@@ -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(
|
||||
|
@@ -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():
|
||||
|
Reference in New Issue
Block a user