From c4bb8dfa64ea0013a90f2ad3b611340ebfa214df Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 17:23:49 -0400 Subject: [PATCH 01/75] Allow non-interactive unlock --- src/password_manager/manager.py | 26 ++++-- src/tests/test_noninteractive_init_unlock.py | 84 ++++++++++++++++++++ 2 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 src/tests/test_noninteractive_init_unlock.py diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index c3c22056..c84b592f 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -117,7 +117,9 @@ class PasswordManager: verification, ensuring the integrity and confidentiality of the stored password database. """ - def __init__(self, fingerprint: Optional[str] = None) -> None: + def __init__( + self, fingerprint: Optional[str] = None, *, password: Optional[str] = None + ) -> None: """Initialize the PasswordManager. Parameters @@ -161,7 +163,7 @@ class PasswordManager: if fingerprint: # Load the specified profile without prompting - self.select_fingerprint(fingerprint) + self.select_fingerprint(fingerprint, password=password) else: # Ensure a parent seed is set up before accessing the fingerprint directory self.setup_parent_seed() @@ -187,6 +189,11 @@ class PasswordManager: ) ) + @staticmethod + def get_password_prompt() -> str: + """Return the standard prompt for requesting a master password.""" + return "Enter your master password: " + @property def parent_seed(self) -> Optional[str]: """Return the decrypted parent seed if set.""" @@ -269,12 +276,15 @@ class PasswordManager: self.config_manager = None self.locked = True - def unlock_vault(self) -> None: - """Prompt for password and reinitialize managers.""" + def unlock_vault(self, password: Optional[str] = None) -> None: + """Unlock the vault using ``password`` without prompting if provided.""" start = time.perf_counter() if not self.fingerprint_dir: raise ValueError("Fingerprint directory not set") - self.setup_encryption_manager(self.fingerprint_dir) + if password is None: + self.setup_encryption_manager(self.fingerprint_dir) + else: + self.setup_encryption_manager(self.fingerprint_dir, password) self.initialize_bip85() self.initialize_managers() self.locked = False @@ -394,7 +404,9 @@ class PasswordManager: print(colored(f"Error: Failed to add new seed profile: {e}", "red")) sys.exit(1) - def select_fingerprint(self, fingerprint: str) -> None: + def select_fingerprint( + self, fingerprint: str, *, password: Optional[str] = None + ) -> None: if self.fingerprint_manager.select_fingerprint(fingerprint): self.current_fingerprint = fingerprint # Add this line self.fingerprint_dir = ( @@ -409,7 +421,7 @@ class PasswordManager: ) sys.exit(1) # Setup the encryption manager and load parent seed - self.setup_encryption_manager(self.fingerprint_dir) + self.setup_encryption_manager(self.fingerprint_dir, password) # Initialize BIP85 and other managers self.initialize_bip85() self.initialize_managers() diff --git a/src/tests/test_noninteractive_init_unlock.py b/src/tests/test_noninteractive_init_unlock.py new file mode 100644 index 00000000..d239fb4f --- /dev/null +++ b/src/tests/test_noninteractive_init_unlock.py @@ -0,0 +1,84 @@ +import importlib +import bcrypt +from pathlib import Path +from tempfile import TemporaryDirectory + +import constants +import password_manager.manager as manager_module +from utils.fingerprint_manager import FingerprintManager +from password_manager.config_manager import ConfigManager +from tests.helpers import TEST_SEED, TEST_PASSWORD, create_vault + + +def test_init_with_password(monkeypatch): + with TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + monkeypatch.setattr(Path, "home", lambda: tmp) + importlib.reload(constants) + importlib.reload(manager_module) + + fm = FingerprintManager(constants.APP_DIR) + fp = fm.add_fingerprint(TEST_SEED) + dir_path = constants.APP_DIR / fp + vault, _enc = create_vault(dir_path, TEST_SEED, TEST_PASSWORD) + cfg = ConfigManager(vault, dir_path) + cfg.set_password_hash( + bcrypt.hashpw(TEST_PASSWORD.encode(), bcrypt.gensalt()).decode() + ) + cfg.set_kdf_iterations(100_000) + + called = {} + + def fake_setup(self, path, pw=None, **_): + called["password"] = pw + return True + + monkeypatch.setattr( + manager_module.PasswordManager, "initialize_bip85", lambda self: None + ) + monkeypatch.setattr( + manager_module.PasswordManager, "initialize_managers", lambda self: None + ) + monkeypatch.setattr( + manager_module.PasswordManager, "setup_encryption_manager", fake_setup + ) + + pm = manager_module.PasswordManager(fingerprint=fp, password=TEST_PASSWORD) + assert called["password"] == TEST_PASSWORD + + +def test_unlock_with_password(monkeypatch): + with TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + monkeypatch.setattr(Path, "home", lambda: tmp) + importlib.reload(constants) + importlib.reload(manager_module) + + fm = FingerprintManager(constants.APP_DIR) + fp = fm.add_fingerprint(TEST_SEED) + dir_path = constants.APP_DIR / fp + vault, _enc = create_vault(dir_path, TEST_SEED, TEST_PASSWORD) + cfg = ConfigManager(vault, dir_path) + cfg.set_password_hash( + bcrypt.hashpw(TEST_PASSWORD.encode(), bcrypt.gensalt()).decode() + ) + + pm = manager_module.PasswordManager.__new__(manager_module.PasswordManager) + pm.fingerprint_dir = dir_path + pm.config_manager = cfg + pm.locked = True + called = {} + + def fake_setup(path, pw=None): + called["password"] = pw + + monkeypatch.setattr( + manager_module.PasswordManager, "initialize_bip85", lambda self: None + ) + monkeypatch.setattr( + manager_module.PasswordManager, "initialize_managers", lambda self: None + ) + pm.setup_encryption_manager = fake_setup + + pm.unlock_vault(TEST_PASSWORD) + assert called["password"] == TEST_PASSWORD From c23b2e4913676875b4200ff3b8b1d2adf4c626b8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 19:21:10 -0400 Subject: [PATCH 02/75] Refactor password manager modules --- scripts/generate_test_profile.py | 10 ++-- scripts/update_checksum.py | 2 +- src/main.py | 4 +- src/nostr/client.py | 8 ++-- src/seedpass/api.py | 4 +- src/seedpass/cli.py | 4 +- .../core}/__init__.py | 2 +- .../core}/backup.py | 4 +- .../core}/config_manager.py | 4 +- .../core}/encryption.py | 2 +- .../core}/entry_management.py | 22 ++++----- .../core}/entry_types.py | 2 +- .../core}/manager.py | 28 +++++------ .../core}/migrations.py | 0 .../core}/password_generation.py | 4 +- .../core}/portable_backup.py | 6 +-- .../core}/seedqr.py | 0 .../core}/totp.py | 0 .../core}/vault.py | 0 src/tests/helpers.py | 4 +- src/tests/test_add_tags_from_retrieve.py | 8 ++-- src/tests/test_additional_backup.py | 6 +-- src/tests/test_archive_from_retrieve.py | 8 ++-- src/tests/test_archive_nonpassword.py | 6 +-- src/tests/test_archive_restore.py | 8 ++-- src/tests/test_background_relay_check.py | 2 +- src/tests/test_background_sync_always.py | 10 ++-- src/tests/test_backup_interval.py | 4 +- src/tests/test_backup_restore.py | 4 +- src/tests/test_bip85_vectors.py | 2 +- src/tests/test_cli_doc_examples.py | 2 +- src/tests/test_cli_export_import.py | 6 +-- src/tests/test_cli_subcommands.py | 2 +- src/tests/test_concurrency_stress.py | 8 ++-- src/tests/test_config_manager.py | 4 +- src/tests/test_custom_fields_display.py | 8 ++-- src/tests/test_default_encryption_mode.py | 2 +- src/tests/test_edit_tags_from_retrieve.py | 8 ++-- src/tests/test_encryption_checksum.py | 2 +- src/tests/test_encryption_files.py | 2 +- src/tests/test_entries_empty.py | 8 ++-- src/tests/test_entry_add.py | 8 ++-- .../test_entry_management_checksum_path.py | 8 ++-- src/tests/test_export_totp_codes.py | 14 +++--- src/tests/test_fingerprint_encryption.py | 2 +- src/tests/test_full_sync_roundtrip.py | 8 ++-- src/tests/test_full_sync_roundtrip_new.py | 8 ++-- src/tests/test_fuzz_key_derivation.py | 2 +- src/tests/test_index_cache.py | 6 +-- src/tests/test_index_import_export.py | 4 +- src/tests/test_kdf_modes.py | 12 ++--- src/tests/test_key_value_entry.py | 6 +-- src/tests/test_last_used_fingerprint.py | 4 +- src/tests/test_list_entries_sort_filter.py | 8 ++-- src/tests/test_managed_account.py | 10 ++-- src/tests/test_managed_account_entry.py | 12 ++--- src/tests/test_manager_add_totp.py | 8 ++-- src/tests/test_manager_checksum_backup.py | 16 +++---- .../test_manager_current_notification.py | 6 +-- src/tests/test_manager_display_totp_codes.py | 12 ++--- src/tests/test_manager_edit_totp.py | 12 ++--- src/tests/test_manager_list_entries.py | 46 +++++++++---------- src/tests/test_manager_retrieve_totp.py | 12 ++--- src/tests/test_manager_search_display.py | 12 ++--- src/tests/test_manager_seed_setup.py | 6 +-- .../test_manager_warning_notifications.py | 12 ++--- src/tests/test_manager_workflow.py | 12 ++--- src/tests/test_migrations.py | 2 +- src/tests/test_modify_totp_entry.py | 6 +-- src/tests/test_multiple_fingerprint_prompt.py | 2 +- src/tests/test_noninteractive_init_unlock.py | 4 +- src/tests/test_nostr_backup.py | 8 ++-- src/tests/test_nostr_client.py | 2 +- src/tests/test_nostr_contract.py | 2 +- src/tests/test_nostr_dummy_client.py | 6 +-- src/tests/test_nostr_entry.py | 8 ++-- src/tests/test_nostr_index_size.py | 10 ++-- src/tests/test_nostr_qr.py | 14 +++--- src/tests/test_nostr_real.py | 2 +- src/tests/test_nostr_snapshot.py | 2 +- src/tests/test_offline_mode_behavior.py | 2 +- src/tests/test_parent_seed_backup.py | 12 ++--- src/tests/test_password_change.py | 18 ++++---- src/tests/test_password_generation_policy.py | 2 +- src/tests/test_password_helpers.py | 2 +- src/tests/test_password_length_constraints.py | 2 +- src/tests/test_password_properties.py | 4 +- .../test_password_unlock_after_change.py | 22 ++++----- src/tests/test_pgp_entry.py | 6 +-- src/tests/test_portable_backup.py | 10 ++-- src/tests/test_profile_cleanup.py | 4 +- src/tests/test_profile_init_integration.py | 4 +- src/tests/test_profile_management.py | 12 ++--- src/tests/test_profiles.py | 6 +-- src/tests/test_publish_json_result.py | 2 +- .../test_retrieve_pause_sensitive_entries.py | 12 ++--- src/tests/test_search_entries.py | 6 +-- src/tests/test_secret_mode.py | 20 ++++---- src/tests/test_seed_entry.py | 8 ++-- src/tests/test_seed_generation.py | 4 +- src/tests/test_seed_import.py | 4 +- src/tests/test_seed_migration.py | 2 +- src/tests/test_seedqr_encoding.py | 2 +- src/tests/test_settings_menu.py | 4 +- src/tests/test_ssh_entry.py | 8 ++-- src/tests/test_ssh_entry_valid.py | 8 ++-- src/tests/test_tag_persistence.py | 6 +-- src/tests/test_totp.py | 4 +- src/tests/test_totp_entry.py | 10 ++-- src/tests/test_totp_uri.py | 2 +- src/tests/test_typer_cli.py | 2 +- src/tests/test_unlock_sync.py | 4 +- src/tests/test_v2_prefix_fallback.py | 6 +-- src/tests/test_vault_initialization.py | 10 ++-- src/tests/test_verbose_timing.py | 8 ++-- 115 files changed, 388 insertions(+), 404 deletions(-) rename src/{password_manager => seedpass/core}/__init__.py (95%) rename src/{password_manager => seedpass/core}/backup.py (98%) rename src/{password_manager => seedpass/core}/config_manager.py (99%) rename src/{password_manager => seedpass/core}/encryption.py (99%) rename src/{password_manager => seedpass/core}/entry_management.py (98%) rename src/{password_manager => seedpass/core}/entry_types.py (91%) rename src/{password_manager => seedpass/core}/manager.py (99%) rename src/{password_manager => seedpass/core}/migrations.py (100%) rename src/{password_manager => seedpass/core}/password_generation.py (99%) rename src/{password_manager => seedpass/core}/portable_backup.py (96%) rename src/{password_manager => seedpass/core}/seedqr.py (100%) rename src/{password_manager => seedpass/core}/totp.py (100%) rename src/{password_manager => seedpass/core}/vault.py (100%) diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py index 2632e9a7..00e7aa03 100644 --- a/scripts/generate_test_profile.py +++ b/scripts/generate_test_profile.py @@ -38,11 +38,11 @@ consts.SCRIPT_CHECKSUM_FILE = consts.APP_DIR / "seedpass_script_checksum.txt" from constants import APP_DIR, initialize_app from utils.key_derivation import derive_key_from_password, derive_index_key -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager -from password_manager.backup import BackupManager -from password_manager.entry_management import EntryManager +from seedpass.core.encryption import EncryptionManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager +from seedpass.core.backup import BackupManager +from seedpass.core.entry_management import EntryManager from nostr.client import NostrClient from utils.fingerprint import generate_fingerprint from utils.fingerprint_manager import FingerprintManager diff --git a/scripts/update_checksum.py b/scripts/update_checksum.py index 537c4155..eb52041d 100644 --- a/scripts/update_checksum.py +++ b/scripts/update_checksum.py @@ -14,7 +14,7 @@ from constants import SCRIPT_CHECKSUM_FILE, initialize_app def main() -> None: """Calculate checksum for the main script and write it to SCRIPT_CHECKSUM_FILE.""" initialize_app() - script_path = SRC_DIR / "password_manager" / "manager.py" + script_path = SRC_DIR / "seedpass/core" / "manager.py" if not update_checksum_file(str(script_path), str(SCRIPT_CHECKSUM_FILE)): raise SystemExit(f"Failed to update checksum for {script_path}") print(f"Updated checksum written to {SCRIPT_CHECKSUM_FILE}") diff --git a/src/main.py b/src/main.py index 61da7f2f..3dea1f78 100644 --- a/src/main.py +++ b/src/main.py @@ -20,9 +20,9 @@ from termcolor import colored from utils.color_scheme import color_text import traceback -from password_manager.manager import PasswordManager +from seedpass.core.manager import PasswordManager from nostr.client import NostrClient -from password_manager.entry_types import EntryType +from seedpass.core.entry_types import EntryType from constants import INACTIVITY_TIMEOUT, initialize_app from utils.password_prompt import PasswordPromptError from utils import ( diff --git a/src/nostr/client.py b/src/nostr/client.py index ea2d817e..16fcc94a 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -26,12 +26,12 @@ from nostr_sdk import EventId, Timestamp from .key_manager import KeyManager as SeedPassKeyManager from .backup_models import Manifest, ChunkMeta, KIND_MANIFEST, KIND_SNAPSHOT_CHUNK -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager from constants import MAX_RETRIES, RETRY_DELAY from utils.file_lock import exclusive_lock if TYPE_CHECKING: # pragma: no cover - imported for type hints - from password_manager.config_manager import ConfigManager + from seedpass.core.config_manager import ConfigManager # Backwards compatibility for tests that patch these symbols KeyManager = SeedPassKeyManager @@ -295,8 +295,8 @@ class NostrClient: if retries is None or delay is None: if self.config_manager is None: - from password_manager.config_manager import ConfigManager - from password_manager.vault import Vault + from seedpass.core.config_manager import ConfigManager + from seedpass.core.vault import Vault cfg_mgr = ConfigManager( Vault(self.encryption_manager, self.fingerprint_dir), diff --git a/src/seedpass/api.py b/src/seedpass/api.py index fdc748c7..2ac4d1b6 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -14,8 +14,8 @@ import asyncio import sys from fastapi.middleware.cors import CORSMiddleware -from password_manager.manager import PasswordManager -from password_manager.entry_types import EntryType +from seedpass.core.manager import PasswordManager +from seedpass.core.entry_types import EntryType app = FastAPI() diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 2ffb0e65..fa0b6589 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -4,8 +4,8 @@ import json import typer -from password_manager.manager import PasswordManager -from password_manager.entry_types import EntryType +from seedpass.core.manager import PasswordManager +from seedpass.core.entry_types import EntryType import uvicorn from . import api as api_module diff --git a/src/password_manager/__init__.py b/src/seedpass/core/__init__.py similarity index 95% rename from src/password_manager/__init__.py rename to src/seedpass/core/__init__.py index fd7cf15d..00d933cd 100644 --- a/src/password_manager/__init__.py +++ b/src/seedpass/core/__init__.py @@ -1,4 +1,4 @@ -# password_manager/__init__.py +# seedpass.core/__init__.py """Expose password manager components with lazy imports.""" diff --git a/src/password_manager/backup.py b/src/seedpass/core/backup.py similarity index 98% rename from src/password_manager/backup.py rename to src/seedpass/core/backup.py index 10da249b..3431051b 100644 --- a/src/password_manager/backup.py +++ b/src/seedpass/core/backup.py @@ -1,4 +1,4 @@ -# password_manager/backup.py +# seedpass.core/backup.py """ Backup Manager Module @@ -19,7 +19,7 @@ import traceback from pathlib import Path from termcolor import colored -from password_manager.config_manager import ConfigManager +from .config_manager import ConfigManager from utils.file_lock import exclusive_lock from constants import APP_DIR diff --git a/src/password_manager/config_manager.py b/src/seedpass/core/config_manager.py similarity index 99% rename from src/password_manager/config_manager.py rename to src/seedpass/core/config_manager.py index eb0e0cf2..f0312ac6 100644 --- a/src/password_manager/config_manager.py +++ b/src/seedpass/core/config_manager.py @@ -10,7 +10,7 @@ from utils.seed_prompt import masked_input import bcrypt -from password_manager.vault import Vault +from .vault import Vault from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS from constants import INACTIVITY_TIMEOUT @@ -251,7 +251,7 @@ class ConfigManager: # Password policy settings def get_password_policy(self) -> "PasswordPolicy": """Return the password complexity policy.""" - from password_manager.password_generation import PasswordPolicy + from .password_generation import PasswordPolicy cfg = self.load_config(require_pin=False) return PasswordPolicy( diff --git a/src/password_manager/encryption.py b/src/seedpass/core/encryption.py similarity index 99% rename from src/password_manager/encryption.py rename to src/seedpass/core/encryption.py index ae214164..d063387a 100644 --- a/src/password_manager/encryption.py +++ b/src/seedpass/core/encryption.py @@ -1,4 +1,4 @@ -# /src/password_manager/encryption.py +# /src/seedpass.core/encryption.py import logging import traceback diff --git a/src/password_manager/entry_management.py b/src/seedpass/core/entry_management.py similarity index 98% rename from src/password_manager/entry_management.py rename to src/seedpass/core/entry_management.py index b779580d..7a9673e8 100644 --- a/src/password_manager/entry_management.py +++ b/src/seedpass/core/entry_management.py @@ -1,4 +1,4 @@ -# password_manager/entry_management.py +# seedpass.core/entry_management.py """ Entry Management Module @@ -31,14 +31,14 @@ from typing import Optional, Tuple, Dict, Any, List from pathlib import Path 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 .migrations import LATEST_VERSION +from .entry_types import EntryType +from .totp import TotpManager from utils.fingerprint import generate_fingerprint from utils.checksum import canonical_json_dumps -from password_manager.vault import Vault -from password_manager.backup import BackupManager +from .vault import Vault +from .backup import BackupManager # Instantiate the logger @@ -312,7 +312,7 @@ class EntryManager: if not entry or (etype != EntryType.SSH.value and kind != EntryType.SSH.value): raise ValueError("Entry is not an SSH key entry") - from password_manager.password_generation import derive_ssh_key_pair + from .password_generation import derive_ssh_key_pair key_index = int(entry.get("index", index)) return derive_ssh_key_pair(parent_seed, key_index) @@ -360,7 +360,7 @@ class EntryManager: if not entry or (etype != EntryType.PGP.value and kind != EntryType.PGP.value): raise ValueError("Entry is not a PGP key entry") - from password_manager.password_generation import derive_pgp_key + from .password_generation import derive_pgp_key from local_bip85.bip85 import BIP85 from bip_utils import Bip39SeedGenerator @@ -501,7 +501,7 @@ class EntryManager: ): raise ValueError("Entry is not a seed entry") - from password_manager.password_generation import derive_seed_phrase + from .password_generation import derive_seed_phrase from local_bip85.bip85 import BIP85 from bip_utils import Bip39SeedGenerator @@ -530,7 +530,7 @@ class EntryManager: if index is None: index = self.get_next_index() - from password_manager.password_generation import derive_seed_phrase + from .password_generation import derive_seed_phrase from local_bip85.bip85 import BIP85 from bip_utils import Bip39SeedGenerator @@ -576,7 +576,7 @@ class EntryManager: ): raise ValueError("Entry is not a managed account entry") - from password_manager.password_generation import derive_seed_phrase + from .password_generation import derive_seed_phrase from local_bip85.bip85 import BIP85 from bip_utils import Bip39SeedGenerator diff --git a/src/password_manager/entry_types.py b/src/seedpass/core/entry_types.py similarity index 91% rename from src/password_manager/entry_types.py rename to src/seedpass/core/entry_types.py index da5bd153..a11643a1 100644 --- a/src/password_manager/entry_types.py +++ b/src/seedpass/core/entry_types.py @@ -1,4 +1,4 @@ -# password_manager/entry_types.py +# seedpass.core/entry_types.py """Enumerations for entry types used by SeedPass.""" from enum import Enum diff --git a/src/password_manager/manager.py b/src/seedpass/core/manager.py similarity index 99% rename from src/password_manager/manager.py rename to src/seedpass/core/manager.py index c84b592f..50d865bc 100644 --- a/src/password_manager/manager.py +++ b/src/seedpass/core/manager.py @@ -1,4 +1,4 @@ -# password_manager/manager.py +# seedpass.core/manager.py """ Password Manager Module @@ -25,14 +25,14 @@ from termcolor import colored from utils.color_scheme import color_text from utils.input_utils import timed_input -from password_manager.encryption import EncryptionManager -from password_manager.entry_management import EntryManager -from password_manager.password_generation import PasswordGenerator -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.portable_backup import export_backup, import_backup -from password_manager.totp import TotpManager -from password_manager.entry_types import EntryType +from .encryption import EncryptionManager +from .entry_management import EntryManager +from .password_generation import PasswordGenerator +from .backup import BackupManager +from .vault import Vault +from .portable_backup import export_backup, import_backup +from .totp import TotpManager +from .entry_types import EntryType from utils.key_derivation import ( derive_key_from_parent_seed, derive_key_from_password, @@ -64,7 +64,7 @@ from utils.terminal_utils import ( ) from utils.fingerprint import generate_fingerprint from constants import MIN_HEALTHY_RELAYS -from password_manager.migrations import LATEST_VERSION +from .migrations import LATEST_VERSION from constants import ( APP_DIR, @@ -94,7 +94,7 @@ from utils.fingerprint_manager import FingerprintManager # Import NostrClient from nostr.client import NostrClient, DEFAULT_RELAYS -from password_manager.config_manager import ConfigManager +from .config_manager import ConfigManager # Instantiate the logger logger = logging.getLogger(__name__) @@ -1579,7 +1579,7 @@ class PasswordManager: print(colored("Seed Phrase:", "cyan")) print(color_text(phrase, "deterministic")) if confirm_action("Show Compact Seed QR? (Y/N): "): - from password_manager.seedqr import encode_seedqr + from .seedqr import encode_seedqr TotpManager.print_qr_code(encode_seedqr(phrase)) try: @@ -1841,7 +1841,7 @@ class PasswordManager: else: print(color_text(seed, "deterministic")) if confirm_action("Show Compact Seed QR? (Y/N): "): - from password_manager.seedqr import encode_seedqr + from .seedqr import encode_seedqr TotpManager.print_qr_code(encode_seedqr(seed)) try: @@ -2075,7 +2075,7 @@ class PasswordManager: ) print(color_text(seed, "deterministic")) - from password_manager.seedqr import encode_seedqr + from .seedqr import encode_seedqr TotpManager.print_qr_code(encode_seedqr(seed)) pause() diff --git a/src/password_manager/migrations.py b/src/seedpass/core/migrations.py similarity index 100% rename from src/password_manager/migrations.py rename to src/seedpass/core/migrations.py diff --git a/src/password_manager/password_generation.py b/src/seedpass/core/password_generation.py similarity index 99% rename from src/password_manager/password_generation.py rename to src/seedpass/core/password_generation.py index b61523f6..a70ab0b8 100644 --- a/src/password_manager/password_generation.py +++ b/src/seedpass/core/password_generation.py @@ -1,4 +1,4 @@ -# password_manager/password_generation.py +# seedpass.core/password_generation.py """ Password Generation Module @@ -43,7 +43,7 @@ except ModuleNotFoundError: # pragma: no cover - fallback for removed module from local_bip85.bip85 import BIP85 from constants import DEFAULT_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH -from password_manager.encryption import EncryptionManager +from .encryption import EncryptionManager # Instantiate the logger logger = logging.getLogger(__name__) diff --git a/src/password_manager/portable_backup.py b/src/seedpass/core/portable_backup.py similarity index 96% rename from src/password_manager/portable_backup.py rename to src/seedpass/core/portable_backup.py index 87318183..a76879be 100644 --- a/src/password_manager/portable_backup.py +++ b/src/seedpass/core/portable_backup.py @@ -12,14 +12,14 @@ import asyncio from enum import Enum from pathlib import Path -from password_manager.vault import Vault -from password_manager.backup import BackupManager +from .vault import Vault +from .backup import BackupManager from nostr.client import NostrClient from utils.key_derivation import ( derive_index_key, EncryptionMode, ) -from password_manager.encryption import EncryptionManager +from .encryption import EncryptionManager from utils.checksum import json_checksum, canonical_json_dumps logger = logging.getLogger(__name__) diff --git a/src/password_manager/seedqr.py b/src/seedpass/core/seedqr.py similarity index 100% rename from src/password_manager/seedqr.py rename to src/seedpass/core/seedqr.py diff --git a/src/password_manager/totp.py b/src/seedpass/core/totp.py similarity index 100% rename from src/password_manager/totp.py rename to src/seedpass/core/totp.py diff --git a/src/password_manager/vault.py b/src/seedpass/core/vault.py similarity index 100% rename from src/password_manager/vault.py rename to src/seedpass/core/vault.py diff --git a/src/tests/helpers.py b/src/tests/helpers.py index 8157bc34..c36fa657 100644 --- a/src/tests/helpers.py +++ b/src/tests/helpers.py @@ -5,8 +5,8 @@ from pathlib import Path sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.vault import Vault -from password_manager.encryption import EncryptionManager +from seedpass.core.vault import Vault +from seedpass.core.encryption import EncryptionManager from utils.key_derivation import ( derive_index_key, derive_key_from_password, diff --git a/src/tests/test_add_tags_from_retrieve.py b/src/tests/test_add_tags_from_retrieve.py index fac58662..d9ed8355 100644 --- a/src/tests/test_add_tags_from_retrieve.py +++ b/src/tests/test_add_tags_from_retrieve.py @@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager class FakePasswordGenerator: diff --git a/src/tests/test_additional_backup.py b/src/tests/test_additional_backup.py index 5597394a..ee7c9dc2 100644 --- a/src/tests/test_additional_backup.py +++ b/src/tests/test_additional_backup.py @@ -4,9 +4,9 @@ from tempfile import TemporaryDirectory from helpers import create_vault, TEST_SEED, TEST_PASSWORD -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def test_entry_manager_additional_backup(monkeypatch): diff --git a/src/tests/test_archive_from_retrieve.py b/src/tests/test_archive_from_retrieve.py index bc094dad..65844baa 100644 --- a/src/tests/test_archive_from_retrieve.py +++ b/src/tests/test_archive_from_retrieve.py @@ -8,10 +8,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager class FakePasswordGenerator: diff --git a/src/tests/test_archive_nonpassword.py b/src/tests/test_archive_nonpassword.py index 6296813a..3a1e6b86 100644 --- a/src/tests/test_archive_nonpassword.py +++ b/src/tests/test_archive_nonpassword.py @@ -6,9 +6,9 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def setup_entry_mgr(tmp_path: Path) -> EntryManager: diff --git a/src/tests/test_archive_restore.py b/src/tests/test_archive_restore.py index 8332fd50..a906274d 100644 --- a/src/tests/test_archive_restore.py +++ b/src/tests/test_archive_restore.py @@ -10,10 +10,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode def setup_entry_mgr(tmp_path: Path) -> EntryManager: diff --git a/src/tests/test_background_relay_check.py b/src/tests/test_background_relay_check.py index d537c707..ecc0c5bb 100644 --- a/src/tests/test_background_relay_check.py +++ b/src/tests/test_background_relay_check.py @@ -6,7 +6,7 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager +from seedpass.core.manager import PasswordManager from constants import MIN_HEALTHY_RELAYS diff --git a/src/tests/test_background_sync_always.py b/src/tests/test_background_sync_always.py index 84faa323..e94a8999 100644 --- a/src/tests/test_background_sync_always.py +++ b/src/tests/test_background_sync_always.py @@ -4,8 +4,8 @@ from pathlib import Path sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager -import password_manager.manager as manager_module +from seedpass.core.manager import PasswordManager +import seedpass.core.manager as manager_module def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path): @@ -23,16 +23,14 @@ def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path): monkeypatch.setattr("builtins.input", lambda *_a, **_k: "1") monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", lambda *_a, **_k: "pw" + "seedpass.core.manager.prompt_existing_password", lambda *_a, **_k: "pw" ) monkeypatch.setattr( PasswordManager, "setup_encryption_manager", lambda *a, **k: True ) monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda *a, **k: None) monkeypatch.setattr(PasswordManager, "initialize_managers", lambda *a, **k: None) - monkeypatch.setattr( - "password_manager.manager.NostrClient", lambda *a, **kw: object() - ) + monkeypatch.setattr("seedpass.core.manager.NostrClient", lambda *a, **kw: object()) calls = {"count": 0} diff --git a/src/tests/test_backup_interval.py b/src/tests/test_backup_interval.py index f7ce39ac..baec9b46 100644 --- a/src/tests/test_backup_interval.py +++ b/src/tests/test_backup_interval.py @@ -4,8 +4,8 @@ from tempfile import TemporaryDirectory from helpers import create_vault, TEST_SEED, TEST_PASSWORD -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def test_backup_interval(monkeypatch): diff --git a/src/tests/test_backup_restore.py b/src/tests/test_backup_restore.py index fdbc221c..d22d6a4e 100644 --- a/src/tests/test_backup_restore.py +++ b/src/tests/test_backup_restore.py @@ -8,8 +8,8 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def test_backup_restore_workflow(monkeypatch): diff --git a/src/tests/test_bip85_vectors.py b/src/tests/test_bip85_vectors.py index 8d62aa33..68938b00 100644 --- a/src/tests/test_bip85_vectors.py +++ b/src/tests/test_bip85_vectors.py @@ -5,7 +5,7 @@ import pytest sys.path.append(str(Path(__file__).resolve().parents[1])) from local_bip85.bip85 import BIP85, Bip85Error -from password_manager.password_generation import ( +from seedpass.core.password_generation import ( derive_ssh_key, derive_seed_phrase, ) diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py index 99379260..ecd208e3 100644 --- a/src/tests/test_cli_doc_examples.py +++ b/src/tests/test_cli_doc_examples.py @@ -8,7 +8,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1] / "src")) from typer.testing import CliRunner from seedpass import cli -from password_manager.entry_types import EntryType +from seedpass.core.entry_types import EntryType class DummyPM: diff --git a/src/tests/test_cli_export_import.py b/src/tests/test_cli_export_import.py index 5e268b73..e01b38d0 100644 --- a/src/tests/test_cli_export_import.py +++ b/src/tests/test_cli_export_import.py @@ -6,9 +6,9 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) import main -from password_manager.portable_backup import export_backup, import_backup -from password_manager.config_manager import ConfigManager -from password_manager.backup import BackupManager +from seedpass.core.portable_backup import export_backup, import_backup +from seedpass.core.config_manager import ConfigManager +from seedpass.core.backup import BackupManager from helpers import create_vault, TEST_SEED diff --git a/src/tests/test_cli_subcommands.py b/src/tests/test_cli_subcommands.py index 56e437f9..751ac745 100644 --- a/src/tests/test_cli_subcommands.py +++ b/src/tests/test_cli_subcommands.py @@ -5,7 +5,7 @@ from pathlib import Path sys.path.append(str(Path(__file__).resolve().parents[1])) import main -from password_manager.entry_types import EntryType +from seedpass.core.entry_types import EntryType def make_pm(search_results, entry=None, totp_code="123456"): diff --git a/src/tests/test_concurrency_stress.py b/src/tests/test_concurrency_stress.py index da79dd4b..2c145c45 100644 --- a/src/tests/test_concurrency_stress.py +++ b/src/tests/test_concurrency_stress.py @@ -6,10 +6,10 @@ from helpers import TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.encryption import EncryptionManager +from seedpass.core.vault import Vault +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager from utils.key_derivation import derive_index_key, derive_key_from_password diff --git a/src/tests/test_config_manager.py b/src/tests/test_config_manager.py index d26e465e..c6ee18e2 100644 --- a/src/tests/test_config_manager.py +++ b/src/tests/test_config_manager.py @@ -7,8 +7,8 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.config_manager import ConfigManager -from password_manager.vault import Vault +from seedpass.core.config_manager import ConfigManager +from seedpass.core.vault import Vault from nostr.client import DEFAULT_RELAYS from constants import INACTIVITY_TIMEOUT diff --git a/src/tests/test_custom_fields_display.py b/src/tests/test_custom_fields_display.py index 04e2d453..b2fc9431 100644 --- a/src/tests/test_custom_fields_display.py +++ b/src/tests/test_custom_fields_display.py @@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager def test_retrieve_entry_shows_custom_fields(monkeypatch, capsys): diff --git a/src/tests/test_default_encryption_mode.py b/src/tests/test_default_encryption_mode.py index dd0108fb..251eeb29 100644 --- a/src/tests/test_default_encryption_mode.py +++ b/src/tests/test_default_encryption_mode.py @@ -6,7 +6,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from types import SimpleNamespace from pathlib import Path -from password_manager.manager import PasswordManager +from seedpass.core.manager import PasswordManager from utils.key_derivation import EncryptionMode diff --git a/src/tests/test_edit_tags_from_retrieve.py b/src/tests/test_edit_tags_from_retrieve.py index 143da532..ab936579 100644 --- a/src/tests/test_edit_tags_from_retrieve.py +++ b/src/tests/test_edit_tags_from_retrieve.py @@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager class FakePasswordGenerator: diff --git a/src/tests/test_encryption_checksum.py b/src/tests/test_encryption_checksum.py index 33d76fc2..c95f82d8 100644 --- a/src/tests/test_encryption_checksum.py +++ b/src/tests/test_encryption_checksum.py @@ -8,7 +8,7 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager from utils.checksum import verify_and_update_checksum diff --git a/src/tests/test_encryption_files.py b/src/tests/test_encryption_files.py index 0332f6fe..04fb5110 100644 --- a/src/tests/test_encryption_files.py +++ b/src/tests/test_encryption_files.py @@ -8,7 +8,7 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager def test_json_save_and_load_round_trip(): diff --git a/src/tests/test_entries_empty.py b/src/tests/test_entries_empty.py index f9700a54..0f05cb47 100644 --- a/src/tests/test_entries_empty.py +++ b/src/tests/test_entries_empty.py @@ -5,10 +5,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager def test_list_entries_empty(): diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index 1714da51..07344bbf 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -8,10 +8,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager def test_add_and_retrieve_entry(): diff --git a/src/tests/test_entry_management_checksum_path.py b/src/tests/test_entry_management_checksum_path.py index 7f75b656..4d456b1a 100644 --- a/src/tests/test_entry_management_checksum_path.py +++ b/src/tests/test_entry_management_checksum_path.py @@ -5,10 +5,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager def test_update_checksum_writes_to_expected_path(): diff --git a/src/tests/test_export_totp_codes.py b/src/tests/test_export_totp_codes.py index 2f474daa..f588e9ed 100644 --- a/src/tests/test_export_totp_codes.py +++ b/src/tests/test_export_totp_codes.py @@ -8,11 +8,11 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager -from password_manager.totp import TotpManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager +from seedpass.core.totp import TotpManager class FakeNostrClient: @@ -42,9 +42,7 @@ def test_handle_export_totp_codes(monkeypatch, tmp_path): export_path = tmp_path / "out.json" monkeypatch.setattr("builtins.input", lambda *a, **k: str(export_path)) - monkeypatch.setattr( - "password_manager.manager.confirm_action", lambda *_a, **_k: False - ) + monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: False) pm.handle_export_totp_codes() diff --git a/src/tests/test_fingerprint_encryption.py b/src/tests/test_fingerprint_encryption.py index a306c1f5..9cc14d7f 100644 --- a/src/tests/test_fingerprint_encryption.py +++ b/src/tests/test_fingerprint_encryption.py @@ -9,7 +9,7 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) from utils.fingerprint import generate_fingerprint -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager def test_generate_fingerprint_deterministic(): diff --git a/src/tests/test_full_sync_roundtrip.py b/src/tests/test_full_sync_roundtrip.py index 64f110ea..cdcde6ad 100644 --- a/src/tests/test_full_sync_roundtrip.py +++ b/src/tests/test_full_sync_roundtrip.py @@ -4,10 +4,10 @@ from tempfile import TemporaryDirectory from helpers import create_vault, dummy_nostr_client -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode def _init_pm(dir_path: Path, client) -> PasswordManager: diff --git a/src/tests/test_full_sync_roundtrip_new.py b/src/tests/test_full_sync_roundtrip_new.py index 64f110ea..cdcde6ad 100644 --- a/src/tests/test_full_sync_roundtrip_new.py +++ b/src/tests/test_full_sync_roundtrip_new.py @@ -4,10 +4,10 @@ from tempfile import TemporaryDirectory from helpers import create_vault, dummy_nostr_client -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode def _init_pm(dir_path: Path, client) -> PasswordManager: diff --git a/src/tests/test_fuzz_key_derivation.py b/src/tests/test_fuzz_key_derivation.py index 45a35b63..89e26c8c 100644 --- a/src/tests/test_fuzz_key_derivation.py +++ b/src/tests/test_fuzz_key_derivation.py @@ -9,7 +9,7 @@ from utils.key_derivation import ( derive_key_from_password_argon2, derive_index_key, ) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager cfg_values = st.one_of( diff --git a/src/tests/test_index_cache.py b/src/tests/test_index_cache.py index e4a054b2..2ee8eac5 100644 --- a/src/tests/test_index_cache.py +++ b/src/tests/test_index_cache.py @@ -3,9 +3,9 @@ from tempfile import TemporaryDirectory from unittest.mock import patch from helpers import create_vault, TEST_SEED, TEST_PASSWORD -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def test_index_caching(): diff --git a/src/tests/test_index_import_export.py b/src/tests/test_index_import_export.py index 04e31940..a9ee75a8 100644 --- a/src/tests/test_index_import_export.py +++ b/src/tests/test_index_import_export.py @@ -7,8 +7,8 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault +from seedpass.core.encryption import EncryptionManager +from seedpass.core.vault import Vault from utils.key_derivation import derive_index_key, derive_key_from_password SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" diff --git a/src/tests/test_kdf_modes.py b/src/tests/test_kdf_modes.py index ab453de4..177d050e 100644 --- a/src/tests/test_kdf_modes.py +++ b/src/tests/test_kdf_modes.py @@ -8,10 +8,10 @@ from utils.key_derivation import ( derive_key_from_password_argon2, derive_index_key, ) -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.encryption import EncryptionManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode TEST_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" TEST_PASSWORD = "pw" @@ -59,12 +59,12 @@ def test_setup_encryption_manager_kdf_modes(monkeypatch): cfg = _setup_profile(path, mode) pm = _make_pm(path, cfg) monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", + "seedpass.core.manager.prompt_existing_password", lambda *_: TEST_PASSWORD, ) if mode == "argon2": monkeypatch.setattr( - "password_manager.manager.derive_key_from_password_argon2", + "seedpass.core.manager.derive_key_from_password_argon2", lambda pw: derive_key_from_password_argon2(pw, **argon_kwargs), ) monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None) diff --git a/src/tests/test_key_value_entry.py b/src/tests/test_key_value_entry.py index 895dfbfd..86a46295 100644 --- a/src/tests/test_key_value_entry.py +++ b/src/tests/test_key_value_entry.py @@ -6,9 +6,9 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def setup_entry_mgr(tmp_path: Path) -> EntryManager: diff --git a/src/tests/test_last_used_fingerprint.py b/src/tests/test_last_used_fingerprint.py index b097c4a2..1e294f97 100644 --- a/src/tests/test_last_used_fingerprint.py +++ b/src/tests/test_last_used_fingerprint.py @@ -3,9 +3,9 @@ from pathlib import Path from tempfile import TemporaryDirectory import constants -import password_manager.manager as manager_module +import seedpass.core.manager as manager_module from utils.fingerprint_manager import FingerprintManager -from password_manager.manager import EncryptionMode +from seedpass.core.manager import EncryptionMode from helpers import TEST_SEED diff --git a/src/tests/test_list_entries_sort_filter.py b/src/tests/test_list_entries_sort_filter.py index f56d3ef6..68d693f5 100644 --- a/src/tests/test_list_entries_sort_filter.py +++ b/src/tests/test_list_entries_sort_filter.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.entry_types import EntryType +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.entry_types import EntryType def setup_entry_manager(tmp_path: Path) -> EntryManager: diff --git a/src/tests/test_managed_account.py b/src/tests/test_managed_account.py index b62d814b..a6d18cb8 100644 --- a/src/tests/test_managed_account.py +++ b/src/tests/test_managed_account.py @@ -4,14 +4,14 @@ 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 +import seedpass.core.manager as manager_module +from seedpass.core.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 seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def setup_entry_manager(tmp_path: Path) -> EntryManager: diff --git a/src/tests/test_managed_account_entry.py b/src/tests/test_managed_account_entry.py index d9d6cefe..abeadac1 100644 --- a/src/tests/test_managed_account_entry.py +++ b/src/tests/test_managed_account_entry.py @@ -4,15 +4,15 @@ 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 +import seedpass.core.manager as manager_module +from seedpass.core.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 seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.password_generation import derive_seed_phrase from local_bip85.bip85 import BIP85 from bip_utils import Bip39SeedGenerator diff --git a/src/tests/test_manager_add_totp.py b/src/tests/test_manager_add_totp.py index 3dd140ac..27ed5501 100644 --- a/src/tests/test_manager_add_totp.py +++ b/src/tests/test_manager_add_totp.py @@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager class FakeNostrClient: diff --git a/src/tests/test_manager_checksum_backup.py b/src/tests/test_manager_checksum_backup.py index cfba90c7..5eeb6325 100644 --- a/src/tests/test_manager_checksum_backup.py +++ b/src/tests/test_manager_checksum_backup.py @@ -3,7 +3,7 @@ from pathlib import Path sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.manager import PasswordManager, EncryptionMode import queue @@ -29,8 +29,8 @@ def test_handle_verify_checksum_success(monkeypatch, tmp_path, capsys): pm = _make_pm() chk_file = tmp_path / "chk.txt" chk_file.write_text("abc") - monkeypatch.setattr("password_manager.manager.SCRIPT_CHECKSUM_FILE", chk_file) - monkeypatch.setattr("password_manager.manager.calculate_checksum", lambda _: "abc") + monkeypatch.setattr("seedpass.core.manager.SCRIPT_CHECKSUM_FILE", chk_file) + monkeypatch.setattr("seedpass.core.manager.calculate_checksum", lambda _: "abc") pm.handle_verify_checksum() out = capsys.readouterr().out assert "Checksum verification passed." in out @@ -40,8 +40,8 @@ def test_handle_verify_checksum_failure(monkeypatch, tmp_path, capsys): pm = _make_pm() chk_file = tmp_path / "chk.txt" chk_file.write_text("xyz") - monkeypatch.setattr("password_manager.manager.SCRIPT_CHECKSUM_FILE", chk_file) - monkeypatch.setattr("password_manager.manager.calculate_checksum", lambda _: "abc") + monkeypatch.setattr("seedpass.core.manager.SCRIPT_CHECKSUM_FILE", chk_file) + monkeypatch.setattr("seedpass.core.manager.calculate_checksum", lambda _: "abc") pm.handle_verify_checksum() out = capsys.readouterr().out assert "Checksum verification failed" in out @@ -50,13 +50,13 @@ def test_handle_verify_checksum_failure(monkeypatch, tmp_path, capsys): def test_handle_verify_checksum_missing(monkeypatch, tmp_path, capsys): pm = _make_pm() chk_file = tmp_path / "chk.txt" - monkeypatch.setattr("password_manager.manager.SCRIPT_CHECKSUM_FILE", chk_file) - monkeypatch.setattr("password_manager.manager.calculate_checksum", lambda _: "abc") + monkeypatch.setattr("seedpass.core.manager.SCRIPT_CHECKSUM_FILE", chk_file) + monkeypatch.setattr("seedpass.core.manager.calculate_checksum", lambda _: "abc") def raise_missing(*_args, **_kwargs): raise FileNotFoundError - monkeypatch.setattr("password_manager.manager.verify_checksum", raise_missing) + monkeypatch.setattr("seedpass.core.manager.verify_checksum", raise_missing) pm.handle_verify_checksum() note = pm.notifications.get_nowait() assert note.level == "WARNING" diff --git a/src/tests/test_manager_current_notification.py b/src/tests/test_manager_current_notification.py index ab94d9e2..a9d341c9 100644 --- a/src/tests/test_manager_current_notification.py +++ b/src/tests/test_manager_current_notification.py @@ -5,7 +5,7 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager, Notification +from seedpass.core.manager import PasswordManager, Notification from constants import NOTIFICATION_DURATION @@ -20,7 +20,7 @@ def _make_pm(): def test_notify_sets_current(monkeypatch): pm = _make_pm() current = {"val": 100.0} - monkeypatch.setattr("password_manager.manager.time.time", lambda: current["val"]) + monkeypatch.setattr("seedpass.core.manager.time.time", lambda: current["val"]) pm.notify("hello") note = pm._current_notification assert hasattr(note, "message") @@ -32,7 +32,7 @@ def test_notify_sets_current(monkeypatch): def test_get_current_notification_ttl(monkeypatch): pm = _make_pm() now = {"val": 0.0} - monkeypatch.setattr("password_manager.manager.time.time", lambda: now["val"]) + monkeypatch.setattr("seedpass.core.manager.time.time", lambda: now["val"]) pm.notify("note1") assert pm.get_current_notification().message == "note1" diff --git a/src/tests/test_manager_display_totp_codes.py b/src/tests/test_manager_display_totp_codes.py index 649bcd25..783e9859 100644 --- a/src/tests/test_manager_display_totp_codes.py +++ b/src/tests/test_manager_display_totp_codes.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager class FakeNostrClient: @@ -50,7 +50,7 @@ def test_handle_display_totp_codes(monkeypatch, capsys): # interrupt the loop after first iteration monkeypatch.setattr( - "password_manager.manager.timed_input", + "seedpass.core.manager.timed_input", lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()), ) @@ -91,7 +91,7 @@ def test_display_totp_codes_excludes_archived(monkeypatch, capsys): ) monkeypatch.setattr( - "password_manager.manager.timed_input", + "seedpass.core.manager.timed_input", lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()), ) diff --git a/src/tests/test_manager_edit_totp.py b/src/tests/test_manager_edit_totp.py index 53e43d45..8d2bbbe3 100644 --- a/src/tests/test_manager_edit_totp.py +++ b/src/tests/test_manager_edit_totp.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager class FakeNostrClient: @@ -49,8 +49,8 @@ def test_edit_totp_period_from_retrieve(monkeypatch): monkeypatch.setattr( pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1 ) - monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) - monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b") + monkeypatch.setattr("seedpass.core.manager.time.sleep", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.timed_input", lambda *a, **k: "b") pm.handle_retrieve_entry() entry = entry_mgr.retrieve_entry(0) diff --git a/src/tests/test_manager_list_entries.py b/src/tests/test_manager_list_entries.py index 444d4209..6e39282b 100644 --- a/src/tests/test_manager_list_entries.py +++ b/src/tests/test_manager_list_entries.py @@ -10,11 +10,11 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.entry_types import EntryType -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.entry_types import EntryType +from seedpass.core.config_manager import ConfigManager def test_handle_list_entries(monkeypatch, capsys): @@ -79,9 +79,9 @@ def test_list_entries_show_details(monkeypatch, capsys): monkeypatch.setattr( pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1 ) - monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.time.sleep", lambda *a, **k: None) monkeypatch.setattr( - "password_manager.manager.timed_input", + "seedpass.core.manager.timed_input", lambda *a, **k: "b", ) @@ -119,7 +119,7 @@ def test_show_entry_details_by_index(monkeypatch): header_calls = [] monkeypatch.setattr( - "password_manager.manager.clear_header_with_notification", + "seedpass.core.manager.clear_header_with_notification", lambda *a, **k: header_calls.append(True), ) @@ -134,9 +134,9 @@ def test_show_entry_details_by_index(monkeypatch): "_entry_actions_menu", lambda *a, **k: call_order.append("actions"), ) - monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None) monkeypatch.setattr( - "password_manager.manager.confirm_action", lambda *a, **k: False + "seedpass.core.manager.confirm_action", lambda *a, **k: False ) pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw123") monkeypatch.setattr(pm, "notify", lambda *a, **k: None) @@ -168,16 +168,14 @@ def _setup_manager(tmp_path): def _detail_common(monkeypatch, pm): monkeypatch.setattr( - "password_manager.manager.clear_header_with_notification", + "seedpass.core.manager.clear_header_with_notification", lambda *a, **k: None, ) - monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None) monkeypatch.setattr("builtins.input", lambda *a, **k: "") - monkeypatch.setattr( - "password_manager.manager.confirm_action", lambda *a, **k: False - ) - monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b") - monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *a, **k: False) + monkeypatch.setattr("seedpass.core.manager.timed_input", lambda *a, **k: "b") + monkeypatch.setattr("seedpass.core.manager.time.sleep", lambda *a, **k: None) monkeypatch.setattr(pm, "notify", lambda *a, **k: None) pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw123") called = [] @@ -300,21 +298,21 @@ def test_show_entry_details_sensitive(monkeypatch, capsys, entry_type): pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw123") monkeypatch.setattr( - "password_manager.manager.confirm_action", lambda *a, **k: True + "seedpass.core.manager.confirm_action", lambda *a, **k: True ) monkeypatch.setattr( - "password_manager.manager.copy_to_clipboard", lambda *a, **k: None + "seedpass.core.manager.copy_to_clipboard", lambda *a, **k: None ) - monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b") - monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.timed_input", lambda *a, **k: "b") + monkeypatch.setattr("seedpass.core.manager.time.sleep", lambda *a, **k: None) monkeypatch.setattr( - "password_manager.manager.TotpManager.print_qr_code", lambda *a, **k: None + "seedpass.core.manager.TotpManager.print_qr_code", lambda *a, **k: None ) monkeypatch.setattr( - "password_manager.manager.clear_header_with_notification", + "seedpass.core.manager.clear_header_with_notification", lambda *a, **k: None, ) - monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None) input_val = "r" if entry_type == "managed_account" else "" monkeypatch.setattr("builtins.input", lambda *a, **k: input_val) diff --git a/src/tests/test_manager_retrieve_totp.py b/src/tests/test_manager_retrieve_totp.py index 2d300adc..8d01c28d 100644 --- a/src/tests/test_manager_retrieve_totp.py +++ b/src/tests/test_manager_retrieve_totp.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode, TotpManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode, TotpManager +from seedpass.core.config_manager import ConfigManager class FakeNostrClient: @@ -49,9 +49,9 @@ def test_handle_retrieve_totp_entry(monkeypatch, capsys): monkeypatch.setattr( pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1 ) - monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.time.sleep", lambda *a, **k: None) monkeypatch.setattr( - "password_manager.manager.timed_input", + "seedpass.core.manager.timed_input", lambda *a, **k: "b", ) diff --git a/src/tests/test_manager_search_display.py b/src/tests/test_manager_search_display.py index 5116ae2e..6781f0b9 100644 --- a/src/tests/test_manager_search_display.py +++ b/src/tests/test_manager_search_display.py @@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager def test_search_entries_prompt_for_details(monkeypatch, capsys): @@ -38,8 +38,8 @@ def test_search_entries_prompt_for_details(monkeypatch, capsys): monkeypatch.setattr( pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1 ) - monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) - monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b") + monkeypatch.setattr("seedpass.core.manager.time.sleep", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.timed_input", lambda *a, **k: "b") inputs = iter(["Example", "0"]) monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) diff --git a/src/tests/test_manager_seed_setup.py b/src/tests/test_manager_seed_setup.py index ea6baf93..3c1a2456 100644 --- a/src/tests/test_manager_seed_setup.py +++ b/src/tests/test_manager_seed_setup.py @@ -1,6 +1,6 @@ import builtins from mnemonic import Mnemonic -from password_manager.manager import PasswordManager +from seedpass.core.manager import PasswordManager from utils import seed_prompt @@ -28,7 +28,7 @@ def test_setup_existing_seed_words(monkeypatch): words = phrase.split() word_iter = iter(words) monkeypatch.setattr( - "password_manager.manager.masked_input", + "seedpass.core.manager.masked_input", lambda *_: next(word_iter), ) # Ensure prompt_seed_words uses the patched function @@ -52,7 +52,7 @@ def test_setup_existing_seed_paste(monkeypatch): called["prompt"] = prompt return phrase - monkeypatch.setattr("password_manager.manager.masked_input", fake_masked_input) + monkeypatch.setattr("seedpass.core.manager.masked_input", fake_masked_input) monkeypatch.setattr( builtins, "input", diff --git a/src/tests/test_manager_warning_notifications.py b/src/tests/test_manager_warning_notifications.py index 55ad85c5..1c55db67 100644 --- a/src/tests/test_manager_warning_notifications.py +++ b/src/tests/test_manager_warning_notifications.py @@ -5,11 +5,11 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager from helpers import create_vault, TEST_SEED, TEST_PASSWORD -from password_manager.config_manager import ConfigManager +from seedpass.core.config_manager import ConfigManager def _make_pm(tmp_path: Path) -> PasswordManager: @@ -34,9 +34,9 @@ def _make_pm(tmp_path: Path) -> PasswordManager: def test_handle_search_entries_no_query(monkeypatch, tmp_path): pm = _make_pm(tmp_path) monkeypatch.setattr( - "password_manager.manager.clear_header_with_notification", lambda *a, **k: None + "seedpass.core.manager.clear_header_with_notification", lambda *a, **k: None ) - monkeypatch.setattr("password_manager.manager.pause", lambda: None) + monkeypatch.setattr("seedpass.core.manager.pause", lambda: None) monkeypatch.setattr("builtins.input", lambda *_: "") pm.handle_search_entries() diff --git a/src/tests/test_manager_workflow.py b/src/tests/test_manager_workflow.py index a5046b2e..99bea0a8 100644 --- a/src/tests/test_manager_workflow.py +++ b/src/tests/test_manager_workflow.py @@ -5,11 +5,11 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.vault import Vault -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.vault import Vault +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager class FakePasswordGenerator: @@ -34,7 +34,7 @@ def test_manager_workflow(monkeypatch): backup_mgr = BackupManager(tmp_path, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) - monkeypatch.setattr("password_manager.manager.NostrClient", FakeNostrClient) + monkeypatch.setattr("seedpass.core.manager.NostrClient", FakeNostrClient) pm = PasswordManager.__new__(PasswordManager) pm.encryption_mode = EncryptionMode.SEED_ONLY diff --git a/src/tests/test_migrations.py b/src/tests/test_migrations.py index 23712036..7f361ad9 100644 --- a/src/tests/test_migrations.py +++ b/src/tests/test_migrations.py @@ -5,7 +5,7 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.migrations import LATEST_VERSION +from seedpass.core.migrations import LATEST_VERSION def setup(tmp_path: Path): diff --git a/src/tests/test_modify_totp_entry.py b/src/tests/test_modify_totp_entry.py index 8e038d60..262f95b4 100644 --- a/src/tests/test_modify_totp_entry.py +++ b/src/tests/test_modify_totp_entry.py @@ -1,9 +1,9 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD import pytest -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def test_modify_totp_entry_period_digits_and_archive(tmp_path): diff --git a/src/tests/test_multiple_fingerprint_prompt.py b/src/tests/test_multiple_fingerprint_prompt.py index f065ac67..c36dfaf2 100644 --- a/src/tests/test_multiple_fingerprint_prompt.py +++ b/src/tests/test_multiple_fingerprint_prompt.py @@ -3,7 +3,7 @@ from pathlib import Path from tempfile import TemporaryDirectory import constants -import password_manager.manager as manager_module +import seedpass.core.manager as manager_module from utils.fingerprint_manager import FingerprintManager from helpers import TEST_SEED diff --git a/src/tests/test_noninteractive_init_unlock.py b/src/tests/test_noninteractive_init_unlock.py index d239fb4f..b6969056 100644 --- a/src/tests/test_noninteractive_init_unlock.py +++ b/src/tests/test_noninteractive_init_unlock.py @@ -4,9 +4,9 @@ from pathlib import Path from tempfile import TemporaryDirectory import constants -import password_manager.manager as manager_module +import seedpass.core.manager as manager_module from utils.fingerprint_manager import FingerprintManager -from password_manager.config_manager import ConfigManager +from seedpass.core.config_manager import ConfigManager from tests.helpers import TEST_SEED, TEST_PASSWORD, create_vault diff --git a/src/tests/test_nostr_backup.py b/src/tests/test_nostr_backup.py index b4ca9982..f2f56c35 100644 --- a/src/tests/test_nostr_backup.py +++ b/src/tests/test_nostr_backup.py @@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager from nostr.client import NostrClient diff --git a/src/tests/test_nostr_client.py b/src/tests/test_nostr_client.py index c3a6e9ae..508eb794 100644 --- a/src/tests/test_nostr_client.py +++ b/src/tests/test_nostr_client.py @@ -9,7 +9,7 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager from nostr.client import NostrClient import nostr.client as nostr_client diff --git a/src/tests/test_nostr_contract.py b/src/tests/test_nostr_contract.py index 5501be01..be56e58b 100644 --- a/src/tests/test_nostr_contract.py +++ b/src/tests/test_nostr_contract.py @@ -8,7 +8,7 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager from nostr.client import NostrClient, Manifest diff --git a/src/tests/test_nostr_dummy_client.py b/src/tests/test_nostr_dummy_client.py index 5284a1e3..db35d7e3 100644 --- a/src/tests/test_nostr_dummy_client.py +++ b/src/tests/test_nostr_dummy_client.py @@ -3,9 +3,9 @@ import gzip import math from helpers import create_vault, dummy_nostr_client -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager from nostr.client import prepare_snapshot from nostr.backup_models import KIND_SNAPSHOT_CHUNK diff --git a/src/tests/test_nostr_entry.py b/src/tests/test_nostr_entry.py index c049850e..b8e1edbf 100644 --- a/src/tests/test_nostr_entry.py +++ b/src/tests/test_nostr_entry.py @@ -8,10 +8,10 @@ from nostr.coincurve_keys import Keys 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.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager def test_nostr_key_determinism(): diff --git a/src/tests/test_nostr_index_size.py b/src/tests/test_nostr_index_size.py index 4277a769..a7598a27 100644 --- a/src/tests/test_nostr_index_size.py +++ b/src/tests/test_nostr_index_size.py @@ -15,11 +15,11 @@ import os sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.encryption import EncryptionManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager from nostr.client import NostrClient, Kind, KindStandard diff --git a/src/tests/test_nostr_qr.py b/src/tests/test_nostr_qr.py index 0ad7fe38..1d032aaa 100644 --- a/src/tests/test_nostr_qr.py +++ b/src/tests/test_nostr_qr.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode, TotpManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode, TotpManager +from seedpass.core.config_manager import ConfigManager from utils.color_scheme import color_text @@ -49,7 +49,7 @@ def test_show_qr_for_nostr_keys(monkeypatch): monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) called = [] monkeypatch.setattr( - "password_manager.manager.TotpManager.print_qr_code", + "seedpass.core.manager.TotpManager.print_qr_code", lambda data: called.append(data), ) @@ -85,7 +85,7 @@ def test_show_private_key_qr(monkeypatch, capsys): monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) called = [] monkeypatch.setattr( - "password_manager.manager.TotpManager.print_qr_code", + "seedpass.core.manager.TotpManager.print_qr_code", lambda data: called.append(data), ) @@ -130,7 +130,7 @@ def test_qr_menu_case_insensitive(monkeypatch): monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) called = [] monkeypatch.setattr( - "password_manager.manager.TotpManager.print_qr_code", + "seedpass.core.manager.TotpManager.print_qr_code", lambda data: called.append(data), ) diff --git a/src/tests/test_nostr_real.py b/src/tests/test_nostr_real.py index 02266264..14c2e89b 100644 --- a/src/tests/test_nostr_real.py +++ b/src/tests/test_nostr_real.py @@ -13,7 +13,7 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager from nostr.client import NostrClient diff --git a/src/tests/test_nostr_snapshot.py b/src/tests/test_nostr_snapshot.py index b466f467..c1d642cd 100644 --- a/src/tests/test_nostr_snapshot.py +++ b/src/tests/test_nostr_snapshot.py @@ -9,7 +9,7 @@ import asyncio from unittest.mock import patch from nostr import prepare_snapshot, NostrClient -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager def test_prepare_snapshot_roundtrip(): diff --git a/src/tests/test_offline_mode_behavior.py b/src/tests/test_offline_mode_behavior.py index 0480207c..df9d1ef8 100644 --- a/src/tests/test_offline_mode_behavior.py +++ b/src/tests/test_offline_mode_behavior.py @@ -1,7 +1,7 @@ import time from types import SimpleNamespace -from password_manager.manager import PasswordManager +from seedpass.core.manager import PasswordManager def test_sync_vault_skips_network(monkeypatch): diff --git a/src/tests/test_parent_seed_backup.py b/src/tests/test_parent_seed_backup.py index ff379a69..1f709208 100644 --- a/src/tests/test_parent_seed_backup.py +++ b/src/tests/test_parent_seed_backup.py @@ -6,7 +6,7 @@ import queue sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.manager import PasswordManager, EncryptionMode from constants import DEFAULT_SEED_BACKUP_FILENAME @@ -25,11 +25,11 @@ def test_handle_backup_reveal_parent_seed_confirm(monkeypatch, tmp_path, capsys) pm = _make_pm(tmp_path) monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", lambda *_: "pw" + "seedpass.core.manager.prompt_existing_password", lambda *_: "pw" ) confirms = iter([True, True]) monkeypatch.setattr( - "password_manager.manager.confirm_action", lambda *_a, **_k: next(confirms) + "seedpass.core.manager.confirm_action", lambda *_a, **_k: next(confirms) ) saved = [] @@ -51,11 +51,9 @@ def test_handle_backup_reveal_parent_seed_cancel(monkeypatch, tmp_path, capsys): pm = _make_pm(tmp_path) monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", lambda *_: "pw" - ) - monkeypatch.setattr( - "password_manager.manager.confirm_action", lambda *_a, **_k: False + "seedpass.core.manager.prompt_existing_password", lambda *_: "pw" ) + monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: False) saved = [] pm.encryption_manager = SimpleNamespace( encrypt_and_save_file=lambda data, path: saved.append((data, path)) diff --git a/src/tests/test_password_change.py b/src/tests/test_password_change.py index 74015599..1a82df03 100644 --- a/src/tests/test_password_change.py +++ b/src/tests/test_password_change.py @@ -8,11 +8,11 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.config_manager import ConfigManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.entry_management import EntryManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.manager import PasswordManager, EncryptionMode def test_change_password_triggers_nostr_backup(monkeypatch): @@ -37,13 +37,11 @@ def test_change_password_triggers_nostr_backup(monkeypatch): pm.verify_password = lambda pw: True monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", lambda *_: "old" - ) - monkeypatch.setattr( - "password_manager.manager.prompt_for_password", lambda: "new" + "seedpass.core.manager.prompt_existing_password", lambda *_: "old" ) + monkeypatch.setattr("seedpass.core.manager.prompt_for_password", lambda: "new") - with patch("password_manager.manager.NostrClient") as MockClient: + with patch("seedpass.core.manager.NostrClient") as MockClient: mock_instance = MockClient.return_value mock_instance.publish_snapshot = AsyncMock(return_value=(None, "abcd")) pm.nostr_client = mock_instance diff --git a/src/tests/test_password_generation_policy.py b/src/tests/test_password_generation_policy.py index 5384075c..a4df4197 100644 --- a/src/tests/test_password_generation_policy.py +++ b/src/tests/test_password_generation_policy.py @@ -4,7 +4,7 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.password_generation import PasswordGenerator, PasswordPolicy +from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy class DummyEnc: diff --git a/src/tests/test_password_helpers.py b/src/tests/test_password_helpers.py index d6f661c9..080e363c 100644 --- a/src/tests/test_password_helpers.py +++ b/src/tests/test_password_helpers.py @@ -1,5 +1,5 @@ import string -from password_manager.password_generation import PasswordGenerator, PasswordPolicy +from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy class DummyEnc: diff --git a/src/tests/test_password_length_constraints.py b/src/tests/test_password_length_constraints.py index eaa4941c..a800f9f4 100644 --- a/src/tests/test_password_length_constraints.py +++ b/src/tests/test_password_length_constraints.py @@ -4,7 +4,7 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.password_generation import PasswordGenerator, PasswordPolicy +from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy from constants import MIN_PASSWORD_LENGTH diff --git a/src/tests/test_password_properties.py b/src/tests/test_password_properties.py index 60fce893..0c5f2bab 100644 --- a/src/tests/test_password_properties.py +++ b/src/tests/test_password_properties.py @@ -5,8 +5,8 @@ from hypothesis import given, strategies as st, settings sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.password_generation import PasswordGenerator, PasswordPolicy -from password_manager.entry_types import EntryType +from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy +from seedpass.core.entry_types import EntryType class DummyEnc: diff --git a/src/tests/test_password_unlock_after_change.py b/src/tests/test_password_unlock_after_change.py index 114b7f15..6f31fbd8 100644 --- a/src/tests/test_password_unlock_after_change.py +++ b/src/tests/test_password_unlock_after_change.py @@ -7,12 +7,12 @@ import bcrypt sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.encryption import EncryptionManager +from seedpass.core.vault import Vault +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode from utils.key_derivation import derive_index_key, derive_key_from_password SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" @@ -61,13 +61,11 @@ def test_password_change_and_unlock(monkeypatch): ) monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", lambda *_: old_pw + "seedpass.core.manager.prompt_existing_password", lambda *_: old_pw ) + monkeypatch.setattr("seedpass.core.manager.prompt_for_password", lambda: new_pw) monkeypatch.setattr( - "password_manager.manager.prompt_for_password", lambda: new_pw - ) - monkeypatch.setattr( - "password_manager.manager.NostrClient", + "seedpass.core.manager.NostrClient", lambda *a, **kw: SimpleNamespace( publish_snapshot=lambda *a, **k: (None, "abcd") ), @@ -77,7 +75,7 @@ def test_password_change_and_unlock(monkeypatch): pm.lock_vault() monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", lambda *_: new_pw + "seedpass.core.manager.prompt_existing_password", lambda *_: new_pw ) monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None) monkeypatch.setattr(PasswordManager, "initialize_managers", lambda self: None) diff --git a/src/tests/test_pgp_entry.py b/src/tests/test_pgp_entry.py index 494d4d56..c1fd37f8 100644 --- a/src/tests/test_pgp_entry.py +++ b/src/tests/test_pgp_entry.py @@ -6,9 +6,9 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def test_pgp_key_determinism(): diff --git a/src/tests/test_portable_backup.py b/src/tests/test_portable_backup.py index dc8910b2..7e1b0ff4 100644 --- a/src/tests/test_portable_backup.py +++ b/src/tests/test_portable_backup.py @@ -9,11 +9,11 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.portable_backup import export_backup, import_backup +from seedpass.core.encryption import EncryptionManager +from seedpass.core.vault import Vault +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.portable_backup import export_backup, import_backup from utils.key_derivation import derive_index_key, derive_key_from_password diff --git a/src/tests/test_profile_cleanup.py b/src/tests/test_profile_cleanup.py index 19594898..d85653f6 100644 --- a/src/tests/test_profile_cleanup.py +++ b/src/tests/test_profile_cleanup.py @@ -11,7 +11,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) def setup_pm(tmp_path): import constants - import password_manager.manager as manager_module + import seedpass.core.manager as manager_module importlib.reload(constants) importlib.reload(manager_module) @@ -30,7 +30,7 @@ def test_generate_seed_cleanup_on_failure(monkeypatch): pm, const, mgr = setup_pm(tmp_path) - with patch("password_manager.manager.confirm_action", return_value=True): + with patch("seedpass.core.manager.confirm_action", return_value=True): monkeypatch.setattr( pm, "save_and_encrypt_seed", diff --git a/src/tests/test_profile_init_integration.py b/src/tests/test_profile_init_integration.py index 62c291a5..484447e8 100644 --- a/src/tests/test_profile_init_integration.py +++ b/src/tests/test_profile_init_integration.py @@ -3,7 +3,7 @@ import importlib.util from pathlib import Path from tempfile import TemporaryDirectory -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.manager import PasswordManager, EncryptionMode def load_script(): @@ -33,7 +33,7 @@ def test_initialize_profile_and_manager(monkeypatch): pm.current_fingerprint = fingerprint monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", + "seedpass.core.manager.prompt_existing_password", lambda *_: gtp.DEFAULT_PASSWORD, ) monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None) diff --git a/src/tests/test_profile_management.py b/src/tests/test_profile_management.py index 7665b87f..e5169a4a 100644 --- a/src/tests/test_profile_management.py +++ b/src/tests/test_profile_management.py @@ -11,12 +11,12 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from utils.fingerprint_manager import FingerprintManager import constants -import password_manager.manager as manager_module -from password_manager.vault import Vault -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import EncryptionMode -from password_manager.config_manager import ConfigManager +import seedpass.core.manager as manager_module +from seedpass.core.vault import Vault +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import EncryptionMode +from seedpass.core.config_manager import ConfigManager def test_add_and_delete_entry(monkeypatch): diff --git a/src/tests/test_profiles.py b/src/tests/test_profiles.py index aec32c8e..fbbf097d 100644 --- a/src/tests/test_profiles.py +++ b/src/tests/test_profiles.py @@ -5,7 +5,7 @@ from tempfile import TemporaryDirectory sys.path.append(str(Path(__file__).resolve().parents[1])) from utils.fingerprint_manager import FingerprintManager -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.manager import PasswordManager, EncryptionMode from helpers import create_vault, dummy_nostr_client import gzip from nostr.backup_models import Manifest, ChunkMeta @@ -32,7 +32,7 @@ def test_add_and_switch_fingerprint(monkeypatch): monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "1") monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", + "seedpass.core.manager.prompt_existing_password", lambda *_a, **_k: "pass", ) monkeypatch.setattr( @@ -47,7 +47,7 @@ def test_add_and_switch_fingerprint(monkeypatch): PasswordManager, "sync_index_from_nostr_if_missing", lambda self: None ) monkeypatch.setattr( - "password_manager.manager.NostrClient", lambda *a, **kw: object() + "seedpass.core.manager.NostrClient", lambda *a, **kw: object() ) assert pm.handle_switch_fingerprint() diff --git a/src/tests/test_publish_json_result.py b/src/tests/test_publish_json_result.py index 0abc6484..bc93cf15 100644 --- a/src/tests/test_publish_json_result.py +++ b/src/tests/test_publish_json_result.py @@ -9,7 +9,7 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager from nostr.client import NostrClient, Manifest diff --git a/src/tests/test_retrieve_pause_sensitive_entries.py b/src/tests/test_retrieve_pause_sensitive_entries.py index dcb719b5..09d38522 100644 --- a/src/tests/test_retrieve_pause_sensitive_entries.py +++ b/src/tests/test_retrieve_pause_sensitive_entries.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager import pytest @@ -45,13 +45,13 @@ def test_pause_before_entry_actions(monkeypatch, adder, needs_confirm): pause_calls = [] monkeypatch.setattr( - "password_manager.manager.pause", lambda *a, **k: pause_calls.append(True) + "seedpass.core.manager.pause", lambda *a, **k: pause_calls.append(True) ) monkeypatch.setattr(pm, "_entry_actions_menu", lambda *a, **k: None) monkeypatch.setattr("builtins.input", lambda *a, **k: str(index)) if needs_confirm: monkeypatch.setattr( - "password_manager.manager.confirm_action", lambda *a, **k: True + "seedpass.core.manager.confirm_action", lambda *a, **k: True ) pm.handle_retrieve_entry() diff --git a/src/tests/test_search_entries.py b/src/tests/test_search_entries.py index 86e6f350..5e3f9212 100644 --- a/src/tests/test_search_entries.py +++ b/src/tests/test_search_entries.py @@ -6,9 +6,9 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def setup_entry_manager(tmp_path: Path) -> EntryManager: diff --git a/src/tests/test_secret_mode.py b/src/tests/test_secret_mode.py index 3a524d75..6c4339de 100644 --- a/src/tests/test_secret_mode.py +++ b/src/tests/test_secret_mode.py @@ -8,10 +8,10 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager def setup_pm(tmp_path): @@ -45,7 +45,7 @@ def test_password_retrieve_secret_mode(monkeypatch, capsys): monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) called = [] monkeypatch.setattr( - "password_manager.manager.copy_to_clipboard", + "seedpass.core.manager.copy_to_clipboard", lambda text, t: called.append((text, t)), ) @@ -67,12 +67,12 @@ def test_totp_display_secret_mode(monkeypatch, capsys): pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 30 ) monkeypatch.setattr( - "password_manager.manager.timed_input", + "seedpass.core.manager.timed_input", lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()), ) called = [] monkeypatch.setattr( - "password_manager.manager.copy_to_clipboard", + "seedpass.core.manager.copy_to_clipboard", lambda text, t: called.append((text, t)), ) @@ -94,7 +94,7 @@ def test_password_retrieve_no_secret_mode(monkeypatch, capsys): monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) called = [] monkeypatch.setattr( - "password_manager.manager.copy_to_clipboard", + "seedpass.core.manager.copy_to_clipboard", lambda *a, **k: called.append((a, k)), ) @@ -117,12 +117,12 @@ def test_totp_display_no_secret_mode(monkeypatch, capsys): pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 30 ) monkeypatch.setattr( - "password_manager.manager.timed_input", + "seedpass.core.manager.timed_input", lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()), ) called = [] monkeypatch.setattr( - "password_manager.manager.copy_to_clipboard", + "seedpass.core.manager.copy_to_clipboard", lambda *a, **k: called.append((a, k)), ) diff --git a/src/tests/test_seed_entry.py b/src/tests/test_seed_entry.py index d7d9d364..06665fef 100644 --- a/src/tests/test_seed_entry.py +++ b/src/tests/test_seed_entry.py @@ -7,10 +7,10 @@ from mnemonic import Mnemonic 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 seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.password_generation import derive_seed_phrase from local_bip85.bip85 import BIP85 from bip_utils import Bip39SeedGenerator diff --git a/src/tests/test_seed_generation.py b/src/tests/test_seed_generation.py index 99f6e57f..eb6b6ca8 100644 --- a/src/tests/test_seed_generation.py +++ b/src/tests/test_seed_generation.py @@ -10,7 +10,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) def setup_password_manager(): """Instantiate PasswordManager using a temporary APP_DIR without running __init__.""" import constants - import password_manager.manager as manager_module + import seedpass.core.manager as manager_module # Reload modules so constants use the mocked home directory importlib.reload(constants) @@ -34,7 +34,7 @@ def test_generate_bip85_and_new_seed(monkeypatch): mnemonic = pm.generate_bip85_seed() assert len(mnemonic.split()) == 12 - with patch("password_manager.manager.confirm_action", return_value=True): + with patch("seedpass.core.manager.confirm_action", return_value=True): fingerprint = pm.generate_new_seed() expected_dir = const.APP_DIR / fingerprint diff --git a/src/tests/test_seed_import.py b/src/tests/test_seed_import.py index eb6db7f6..7cbbe6d4 100644 --- a/src/tests/test_seed_import.py +++ b/src/tests/test_seed_import.py @@ -7,8 +7,8 @@ from mnemonic import Mnemonic sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.encryption import EncryptionManager +from seedpass.core.manager import PasswordManager, EncryptionMode def test_seed_encryption_round_trip(): diff --git a/src/tests/test_seed_migration.py b/src/tests/test_seed_migration.py index a273be32..845dfaa5 100644 --- a/src/tests/test_seed_migration.py +++ b/src/tests/test_seed_migration.py @@ -7,7 +7,7 @@ from utils.key_derivation import derive_key_from_password sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager def test_parent_seed_migrates_from_fernet(tmp_path: Path) -> None: diff --git a/src/tests/test_seedqr_encoding.py b/src/tests/test_seedqr_encoding.py index 7d0b5e77..b986ee0c 100644 --- a/src/tests/test_seedqr_encoding.py +++ b/src/tests/test_seedqr_encoding.py @@ -3,7 +3,7 @@ from pathlib import Path sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.seedqr import encode_seedqr +from seedpass.core.seedqr import encode_seedqr def test_seedqr_standard_example(): diff --git a/src/tests/test_settings_menu.py b/src/tests/test_settings_menu.py index 68998221..d7699d0b 100644 --- a/src/tests/test_settings_menu.py +++ b/src/tests/test_settings_menu.py @@ -11,8 +11,8 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) import main from nostr.client import DEFAULT_RELAYS -from password_manager.config_manager import ConfigManager -from password_manager.vault import Vault +from seedpass.core.config_manager import ConfigManager +from seedpass.core.vault import Vault from utils.fingerprint_manager import FingerprintManager diff --git a/src/tests/test_ssh_entry.py b/src/tests/test_ssh_entry.py index f0374378..b0d8a4de 100644 --- a/src/tests/test_ssh_entry.py +++ b/src/tests/test_ssh_entry.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager def test_add_and_retrieve_ssh_key_pair(): diff --git a/src/tests/test_ssh_entry_valid.py b/src/tests/test_ssh_entry_valid.py index 9b7ecad9..c74945d6 100644 --- a/src/tests/test_ssh_entry_valid.py +++ b/src/tests/test_ssh_entry_valid.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager from cryptography.hazmat.primitives import serialization diff --git a/src/tests/test_tag_persistence.py b/src/tests/test_tag_persistence.py index 487fb3ce..3de796fd 100644 --- a/src/tests/test_tag_persistence.py +++ b/src/tests/test_tag_persistence.py @@ -6,9 +6,9 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def setup_entry_manager(tmp_path: Path) -> EntryManager: diff --git a/src/tests/test_totp.py b/src/tests/test_totp.py index ddaacd97..62ea4739 100644 --- a/src/tests/test_totp.py +++ b/src/tests/test_totp.py @@ -7,7 +7,7 @@ from freezegun import freeze_time sys.path.append(str(Path(__file__).resolve().parents[1])) from helpers import TEST_SEED -from password_manager.totp import TotpManager +from seedpass.core.totp import TotpManager @freeze_time("1970-01-01 00:16:40") @@ -25,6 +25,6 @@ def test_time_remaining(): def test_print_progress_bar_terminates(monkeypatch): monkeypatch.setattr(TotpManager, "time_remaining", lambda period: 0) calls = [] - monkeypatch.setattr("password_manager.totp.time.sleep", lambda s: calls.append(s)) + monkeypatch.setattr("seedpass.core.totp.time.sleep", lambda s: calls.append(s)) TotpManager.print_progress_bar(period=30) assert calls == [] diff --git a/src/tests/test_totp_entry.py b/src/tests/test_totp_entry.py index 6eb0b12e..eff49886 100644 --- a/src/tests/test_totp_entry.py +++ b/src/tests/test_totp_entry.py @@ -9,11 +9,11 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager -from password_manager.totp import TotpManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager +from seedpass.core.totp import TotpManager import pyotp diff --git a/src/tests/test_totp_uri.py b/src/tests/test_totp_uri.py index 26b8429d..1970794b 100644 --- a/src/tests/test_totp_uri.py +++ b/src/tests/test_totp_uri.py @@ -5,7 +5,7 @@ import pytest sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.totp import TotpManager +from seedpass.core.totp import TotpManager # Test parsing a normal otpauth URI with custom period and digits diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index 878fd0c5..fc9a093d 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -8,7 +8,7 @@ from typer.testing import CliRunner from seedpass.cli import app, PasswordManager from seedpass import cli -from password_manager.entry_types import EntryType +from seedpass.core.entry_types import EntryType runner = CliRunner() diff --git a/src/tests/test_unlock_sync.py b/src/tests/test_unlock_sync.py index d618974c..dffd6190 100644 --- a/src/tests/test_unlock_sync.py +++ b/src/tests/test_unlock_sync.py @@ -5,8 +5,8 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager -from password_manager import manager as manager_module +from seedpass.core.manager import PasswordManager +from seedpass.core import manager as manager_module def test_unlock_triggers_sync(monkeypatch, tmp_path): diff --git a/src/tests/test_v2_prefix_fallback.py b/src/tests/test_v2_prefix_fallback.py index 0d23cbf7..64604091 100644 --- a/src/tests/test_v2_prefix_fallback.py +++ b/src/tests/test_v2_prefix_fallback.py @@ -7,7 +7,7 @@ from cryptography.fernet import InvalidToken from helpers import TEST_SEED from utils.key_derivation import derive_index_key -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager def test_v2_prefix_fernet_fallback(tmp_path: Path, caplog) -> None: @@ -18,7 +18,7 @@ def test_v2_prefix_fernet_fallback(tmp_path: Path, caplog) -> None: token = manager.fernet.encrypt(original) payload = b"V2:" + token - caplog.set_level(logging.WARNING, logger="password_manager.encryption") + caplog.set_level(logging.WARNING, logger="seedpass.core.encryption") decrypted = manager.decrypt_data(payload) assert decrypted == original @@ -31,7 +31,7 @@ def test_aesgcm_payload_too_short(tmp_path: Path, caplog) -> None: payload = b"V2:" + os.urandom(12) + b"short" - caplog.set_level(logging.ERROR, logger="password_manager.encryption") + caplog.set_level(logging.ERROR, logger="seedpass.core.encryption") with pytest.raises(InvalidToken, match="AES-GCM payload too short"): manager.decrypt_data(payload) diff --git a/src/tests/test_vault_initialization.py b/src/tests/test_vault_initialization.py index 38e90c83..f3b0d574 100644 --- a/src/tests/test_vault_initialization.py +++ b/src/tests/test_vault_initialization.py @@ -5,8 +5,8 @@ from unittest.mock import patch sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.vault import Vault +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.vault import Vault VALID_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" @@ -20,11 +20,9 @@ def test_save_and_encrypt_seed_initializes_vault(monkeypatch): pm.config_manager = None pm.current_fingerprint = "fp" + monkeypatch.setattr("seedpass.core.manager.prompt_for_password", lambda: "pw") monkeypatch.setattr( - "password_manager.manager.prompt_for_password", lambda: "pw" - ) - monkeypatch.setattr( - "password_manager.manager.NostrClient", lambda *a, **kw: object() + "seedpass.core.manager.NostrClient", lambda *a, **kw: object() ) pm.save_and_encrypt_seed(VALID_SEED, tmp_path) diff --git a/src/tests/test_verbose_timing.py b/src/tests/test_verbose_timing.py index 79cd5caf..45c1b61d 100644 --- a/src/tests/test_verbose_timing.py +++ b/src/tests/test_verbose_timing.py @@ -1,7 +1,7 @@ import asyncio import logging -from password_manager.manager import PasswordManager +from seedpass.core.manager import PasswordManager from helpers import dummy_nostr_client @@ -13,11 +13,9 @@ def test_unlock_vault_logs_time(monkeypatch, caplog, tmp_path): pm.initialize_managers = lambda: None pm.update_activity = lambda: None pm.verbose_timing = True - caplog.set_level(logging.INFO, logger="password_manager.manager") + caplog.set_level(logging.INFO, logger="seedpass.core.manager") times = iter([0.0, 1.0]) - monkeypatch.setattr( - "password_manager.manager.time.perf_counter", lambda: next(times) - ) + monkeypatch.setattr("seedpass.core.manager.time.perf_counter", lambda: next(times)) pm.unlock_vault() assert "Vault unlocked in 1.00 seconds" in caplog.text From 8bd9a756291d0d75904632f9bb7d776ac12ce2c0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 19:38:28 -0400 Subject: [PATCH 03/75] Parametrize password actions --- src/seedpass/api.py | 6 ++- src/seedpass/cli.py | 22 +++++++++- src/seedpass/core/manager.py | 41 +++++++++---------- src/tests/test_api.py | 10 +++-- src/tests/test_api_new_endpoints.py | 4 +- src/tests/test_cli_doc_examples.py | 2 +- src/tests/test_password_change.py | 7 +--- .../test_password_unlock_after_change.py | 4 +- src/tests/test_typer_cli.py | 8 ++-- src/tests/test_unlock_sync.py | 2 +- src/tests/test_verbose_timing.py | 4 +- 11 files changed, 64 insertions(+), 46 deletions(-) diff --git a/src/seedpass/api.py b/src/seedpass/api.py index 2ac4d1b6..134575ab 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -554,11 +554,13 @@ def backup_parent_seed( @app.post("/api/v1/change-password") -def change_password(authorization: str | None = Header(None)) -> dict[str, str]: +def change_password( + data: dict, authorization: str | None = Header(None) +) -> dict[str, str]: """Change the master password for the active profile.""" _check_token(authorization) assert _pm is not None - _pm.change_password() + _pm.change_password(data.get("old", ""), data.get("new", "")) return {"status": "ok"} diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index fa0b6589..d01805fb 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -384,7 +384,27 @@ def vault_import( def vault_change_password(ctx: typer.Context) -> None: """Change the master password used for encryption.""" pm = _get_pm(ctx) - pm.change_password() + old_pw = typer.prompt("Current password", hide_input=True) + new_pw = typer.prompt("New password", hide_input=True, confirmation_prompt=True) + try: + pm.change_password(old_pw, new_pw) + except Exception as exc: # pragma: no cover - pass through errors + typer.echo(f"Error: {exc}") + raise typer.Exit(code=1) + typer.echo("Password updated") + + +@vault_app.command("unlock") +def vault_unlock(ctx: typer.Context) -> None: + """Unlock the vault for the active profile.""" + pm = _get_pm(ctx) + password = typer.prompt("Master password", hide_input=True) + try: + duration = pm.unlock_vault(password) + except Exception as exc: # pragma: no cover - pass through errors + typer.echo(f"Error: {exc}") + raise typer.Exit(code=1) + typer.echo(f"Unlocked in {duration:.2f}s") @vault_app.command("lock") diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 50d865bc..5ef90892 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -276,28 +276,31 @@ class PasswordManager: self.config_manager = None self.locked = True - def unlock_vault(self, password: Optional[str] = None) -> None: - """Unlock the vault using ``password`` without prompting if provided.""" + def unlock_vault(self, password: str) -> float: + """Unlock the vault using the provided ``password``. + + Parameters + ---------- + password: + Master password for the active profile. + + Returns + ------- + float + Duration of the unlock process in seconds. + """ start = time.perf_counter() if not self.fingerprint_dir: raise ValueError("Fingerprint directory not set") - if password is None: - self.setup_encryption_manager(self.fingerprint_dir) - else: - self.setup_encryption_manager(self.fingerprint_dir, password) + self.setup_encryption_manager(self.fingerprint_dir, password) self.initialize_bip85() self.initialize_managers() self.locked = False self.update_activity() self.last_unlock_duration = time.perf_counter() - start - print( - colored( - f"Vault unlocked in {self.last_unlock_duration:.2f} seconds", - "yellow", - ) - ) if getattr(self, "verbose_timing", False): logger.info("Vault unlocked in %.2f seconds", self.last_unlock_duration) + return self.last_unlock_duration def initialize_fingerprint_manager(self): """ @@ -3884,15 +3887,11 @@ class PasswordManager: print(colored(f"Error: Failed to store hashed password: {e}", "red")) raise - def change_password(self) -> None: + def change_password(self, old_password: str, new_password: str) -> None: """Change the master password used for encryption.""" try: - current = prompt_existing_password("Enter your current master password: ") - if not self.verify_password(current): - print(colored("Incorrect password.", "red")) - return - - new_password = prompt_for_password() + if not self.verify_password(old_password): + raise ValueError("Incorrect password") # Load data with existing encryption manager index_data = self.vault.load_index() @@ -3927,8 +3926,6 @@ class PasswordManager: parent_seed=getattr(self, "parent_seed", None), ) - print(colored("Master password changed successfully.", "green")) - # Push a fresh backup to Nostr so the newly encrypted index is # stored remotely. Include a tag to mark the password change. try: @@ -3940,7 +3937,7 @@ class PasswordManager: ) except Exception as e: logging.error(f"Failed to change password: {e}", exc_info=True) - print(colored(f"Error: Failed to change password: {e}", "red")) + raise def get_profile_stats(self) -> dict: """Return various statistics about the current seed profile.""" diff --git a/src/tests/test_api.py b/src/tests/test_api.py index 67f1b478..1aa4c086 100644 --- a/src/tests/test_api.py +++ b/src/tests/test_api.py @@ -179,12 +179,16 @@ def test_change_password_route(client): cl, token = client called = {} - api._pm.change_password = lambda: called.setdefault("called", True) + api._pm.change_password = lambda o, n: called.setdefault("called", (o, n)) headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"} - res = cl.post("/api/v1/change-password", headers=headers) + res = cl.post( + "/api/v1/change-password", + headers=headers, + json={"old": "old", "new": "new"}, + ) assert res.status_code == 200 assert res.json() == {"status": "ok"} - assert called.get("called") is True + assert called.get("called") == ("old", "new") assert res.headers.get("access-control-allow-origin") == "http://example.com" diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index dda8d0b0..337a7240 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -291,8 +291,8 @@ def test_vault_lock_endpoint(client): assert res.json() == {"status": "locked"} assert called.get("locked") is True assert api._pm.locked is True - api._pm.unlock_vault = lambda: setattr(api._pm, "locked", False) - api._pm.unlock_vault() + api._pm.unlock_vault = lambda pw: setattr(api._pm, "locked", False) + api._pm.unlock_vault("pw") assert api._pm.locked is False diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py index ecd208e3..f20a2f6c 100644 --- a/src/tests/test_cli_doc_examples.py +++ b/src/tests/test_cli_doc_examples.py @@ -40,7 +40,7 @@ class DummyPM: self.handle_display_totp_codes = lambda: None self.handle_export_database = lambda path: None self.handle_import_database = lambda path: None - self.change_password = lambda: None + self.change_password = lambda *a, **kw: None self.lock_vault = lambda: None self.get_profile_stats = lambda: {"n": 1} self.handle_backup_reveal_parent_seed = lambda path=None: None diff --git a/src/tests/test_password_change.py b/src/tests/test_password_change.py index 1a82df03..efe001df 100644 --- a/src/tests/test_password_change.py +++ b/src/tests/test_password_change.py @@ -36,14 +36,9 @@ def test_change_password_triggers_nostr_backup(monkeypatch): pm.store_hashed_password = lambda pw: None pm.verify_password = lambda pw: True - monkeypatch.setattr( - "seedpass.core.manager.prompt_existing_password", lambda *_: "old" - ) - monkeypatch.setattr("seedpass.core.manager.prompt_for_password", lambda: "new") - with patch("seedpass.core.manager.NostrClient") as MockClient: mock_instance = MockClient.return_value mock_instance.publish_snapshot = AsyncMock(return_value=(None, "abcd")) pm.nostr_client = mock_instance - pm.change_password() + pm.change_password("old", "new") mock_instance.publish_snapshot.assert_called_once() diff --git a/src/tests/test_password_unlock_after_change.py b/src/tests/test_password_unlock_after_change.py index 6f31fbd8..a0bda0a1 100644 --- a/src/tests/test_password_unlock_after_change.py +++ b/src/tests/test_password_unlock_after_change.py @@ -71,7 +71,7 @@ def test_password_change_and_unlock(monkeypatch): ), ) - pm.change_password() + pm.change_password(old_pw, new_pw) pm.lock_vault() monkeypatch.setattr( @@ -81,7 +81,7 @@ def test_password_change_and_unlock(monkeypatch): monkeypatch.setattr(PasswordManager, "initialize_managers", lambda self: None) monkeypatch.setattr(PasswordManager, "sync_index_from_nostr", lambda self: None) - pm.unlock_vault() + pm.unlock_vault(new_pw) assert pm.parent_seed == SEED assert pm.verify_password(new_pw) diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index fc9a093d..cce6e6ea 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -126,14 +126,14 @@ def test_vault_import_triggers_sync(monkeypatch, tmp_path): def test_vault_change_password(monkeypatch): called = {} - def change_pw(): - called["called"] = True + def change_pw(old, new): + called["args"] = (old, new) pm = SimpleNamespace(change_password=change_pw, select_fingerprint=lambda fp: None) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) - result = runner.invoke(app, ["vault", "change-password"]) + result = runner.invoke(app, ["vault", "change-password"], input="old\nnew\nnew\n") assert result.exit_code == 0 - assert called.get("called") is True + assert called.get("args") == ("old", "new") def test_vault_lock(monkeypatch): diff --git a/src/tests/test_unlock_sync.py b/src/tests/test_unlock_sync.py index dffd6190..a2852c4d 100644 --- a/src/tests/test_unlock_sync.py +++ b/src/tests/test_unlock_sync.py @@ -22,7 +22,7 @@ def test_unlock_triggers_sync(monkeypatch, tmp_path): monkeypatch.setattr(PasswordManager, "sync_index_from_nostr", fake_sync) - pm.unlock_vault() + pm.unlock_vault("pw") pm.start_background_sync() time.sleep(0.05) diff --git a/src/tests/test_verbose_timing.py b/src/tests/test_verbose_timing.py index 45c1b61d..a623b256 100644 --- a/src/tests/test_verbose_timing.py +++ b/src/tests/test_verbose_timing.py @@ -8,7 +8,7 @@ from helpers import dummy_nostr_client def test_unlock_vault_logs_time(monkeypatch, caplog, tmp_path): pm = PasswordManager.__new__(PasswordManager) pm.fingerprint_dir = tmp_path - pm.setup_encryption_manager = lambda path: None + pm.setup_encryption_manager = lambda path, pw=None: None pm.initialize_bip85 = lambda: None pm.initialize_managers = lambda: None pm.update_activity = lambda: None @@ -16,7 +16,7 @@ def test_unlock_vault_logs_time(monkeypatch, caplog, tmp_path): caplog.set_level(logging.INFO, logger="seedpass.core.manager") times = iter([0.0, 1.0]) monkeypatch.setattr("seedpass.core.manager.time.perf_counter", lambda: next(times)) - pm.unlock_vault() + pm.unlock_vault("pw") assert "Vault unlocked in 1.00 seconds" in caplog.text From 72faee02b62e353964c847a3787110ec0cb4a6be Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 19:54:54 -0400 Subject: [PATCH 04/75] Add service layer and update CLI --- src/seedpass/cli.py | 84 +++++++++++------ src/seedpass/core/api.py | 196 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+), 30 deletions(-) create mode 100644 src/seedpass/core/api.py diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index d01805fb..07da8e58 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -6,6 +6,18 @@ import typer from seedpass.core.manager import PasswordManager from seedpass.core.entry_types import EntryType +from seedpass.core.api import ( + VaultService, + ProfileService, + SyncService, + VaultExportRequest, + VaultImportRequest, + ChangePasswordRequest, + UnlockRequest, + BackupParentSeedRequest, + ProfileSwitchRequest, + ProfileRemoveRequest, +) import uvicorn from . import api as api_module @@ -52,6 +64,15 @@ def _get_pm(ctx: typer.Context) -> PasswordManager: return pm +def _get_services( + ctx: typer.Context, +) -> tuple[VaultService, ProfileService, SyncService]: + """Return service layer instances for the current context.""" + + pm = _get_pm(ctx) + return VaultService(pm), ProfileService(pm), SyncService(pm) + + @app.callback(invoke_without_command=True) def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None: """SeedPass CLI entry point. @@ -364,8 +385,8 @@ def vault_export( ctx: typer.Context, file: str = typer.Option(..., help="Output file") ) -> None: """Export the vault.""" - pm = _get_pm(ctx) - pm.handle_export_database(Path(file)) + vault_service, _profile, _sync = _get_services(ctx) + vault_service.export_vault(VaultExportRequest(path=Path(file))) typer.echo(str(file)) @@ -374,20 +395,21 @@ def vault_import( ctx: typer.Context, file: str = typer.Option(..., help="Input file") ) -> None: """Import a vault from an encrypted JSON file.""" - pm = _get_pm(ctx) - pm.handle_import_database(Path(file)) - pm.sync_vault() + vault_service, _profile, _sync = _get_services(ctx) + vault_service.import_vault(VaultImportRequest(path=Path(file))) typer.echo(str(file)) @vault_app.command("change-password") def vault_change_password(ctx: typer.Context) -> None: """Change the master password used for encryption.""" - pm = _get_pm(ctx) + vault_service, _profile, _sync = _get_services(ctx) old_pw = typer.prompt("Current password", hide_input=True) new_pw = typer.prompt("New password", hide_input=True, confirmation_prompt=True) try: - pm.change_password(old_pw, new_pw) + vault_service.change_password( + ChangePasswordRequest(old_password=old_pw, new_password=new_pw) + ) except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error: {exc}") raise typer.Exit(code=1) @@ -397,29 +419,29 @@ def vault_change_password(ctx: typer.Context) -> None: @vault_app.command("unlock") def vault_unlock(ctx: typer.Context) -> None: """Unlock the vault for the active profile.""" - pm = _get_pm(ctx) + vault_service, _profile, _sync = _get_services(ctx) password = typer.prompt("Master password", hide_input=True) try: - duration = pm.unlock_vault(password) + resp = vault_service.unlock(UnlockRequest(password=password)) except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error: {exc}") raise typer.Exit(code=1) - typer.echo(f"Unlocked in {duration:.2f}s") + typer.echo(f"Unlocked in {resp.duration:.2f}s") @vault_app.command("lock") def vault_lock(ctx: typer.Context) -> None: """Lock the vault and clear sensitive data from memory.""" - pm = _get_pm(ctx) - pm.lock_vault() + vault_service, _profile, _sync = _get_services(ctx) + vault_service.lock() typer.echo("locked") @vault_app.command("stats") def vault_stats(ctx: typer.Context) -> None: """Display statistics about the current seed profile.""" - pm = _get_pm(ctx) - stats = pm.get_profile_stats() + vault_service, _profile, _sync = _get_services(ctx) + stats = vault_service.stats() typer.echo(json.dumps(stats, indent=2)) @@ -431,21 +453,23 @@ def vault_reveal_parent_seed( ), ) -> None: """Display the parent seed and optionally write an encrypted backup file.""" - pm = _get_pm(ctx) - pm.handle_backup_reveal_parent_seed(Path(file) if file else None) + vault_service, _profile, _sync = _get_services(ctx) + vault_service.backup_parent_seed( + BackupParentSeedRequest(path=Path(file) if file else None) + ) @nostr_app.command("sync") def nostr_sync(ctx: typer.Context) -> None: """Sync with configured Nostr relays.""" - pm = _get_pm(ctx) - result = pm.sync_vault() - if result: + _vault, _profile, sync_service = _get_services(ctx) + model = sync_service.sync() + if model: typer.echo("Event IDs:") - typer.echo(f"- manifest: {result['manifest_id']}") - for cid in result["chunk_ids"]: + typer.echo(f"- manifest: {model.manifest_id}") + for cid in model.chunk_ids: typer.echo(f"- chunk: {cid}") - for did in result["delta_ids"]: + for did in model.delta_ids: typer.echo(f"- delta: {did}") else: typer.echo("Error: Failed to sync vault") @@ -606,30 +630,30 @@ def config_toggle_offline(ctx: typer.Context) -> None: @fingerprint_app.command("list") def fingerprint_list(ctx: typer.Context) -> None: """List available seed profiles.""" - pm = _get_pm(ctx) - for fp in pm.fingerprint_manager.list_fingerprints(): + _vault, profile_service, _sync = _get_services(ctx) + for fp in profile_service.list_profiles(): typer.echo(fp) @fingerprint_app.command("add") def fingerprint_add(ctx: typer.Context) -> None: """Create a new seed profile.""" - pm = _get_pm(ctx) - pm.add_new_fingerprint() + _vault, profile_service, _sync = _get_services(ctx) + profile_service.add_profile() @fingerprint_app.command("remove") def fingerprint_remove(ctx: typer.Context, fingerprint: str) -> None: """Remove a seed profile.""" - pm = _get_pm(ctx) - pm.fingerprint_manager.remove_fingerprint(fingerprint) + _vault, profile_service, _sync = _get_services(ctx) + profile_service.remove_profile(ProfileRemoveRequest(fingerprint=fingerprint)) @fingerprint_app.command("switch") def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None: """Switch to another seed profile.""" - pm = _get_pm(ctx) - pm.select_fingerprint(fingerprint) + _vault, profile_service, _sync = _get_services(ctx) + profile_service.switch_profile(ProfileSwitchRequest(fingerprint=fingerprint)) @util_app.command("generate-password") diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py new file mode 100644 index 00000000..03609a8a --- /dev/null +++ b/src/seedpass/core/api.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +"""Service layer wrapping :class:`PasswordManager` operations. + +These services provide thread-safe methods for common operations used by the CLI +and API. Request and response payloads are represented using Pydantic models to +allow easy validation and documentation. +""" + +from pathlib import Path +from threading import Lock +from typing import List, Optional, Dict + +from pydantic import BaseModel + +from .manager import PasswordManager + + +class VaultExportRequest(BaseModel): + """Parameters required to export the vault.""" + + path: Path + + +class VaultExportResponse(BaseModel): + """Result of a vault export operation.""" + + path: Path + + +class VaultImportRequest(BaseModel): + """Parameters required to import a vault.""" + + path: Path + + +class ChangePasswordRequest(BaseModel): + """Payload for :meth:`VaultService.change_password`.""" + + old_password: str + new_password: str + + +class UnlockRequest(BaseModel): + """Payload for unlocking the vault.""" + + password: str + + +class UnlockResponse(BaseModel): + """Duration taken to unlock the vault.""" + + duration: float + + +class BackupParentSeedRequest(BaseModel): + """Optional path to write the encrypted seed backup.""" + + path: Optional[Path] = None + + +class ProfileSwitchRequest(BaseModel): + """Select a different seed profile.""" + + fingerprint: str + + +class ProfileRemoveRequest(BaseModel): + """Remove a seed profile.""" + + fingerprint: str + + +class SyncResponse(BaseModel): + """Information about uploaded events after syncing.""" + + manifest_id: str + chunk_ids: List[str] = [] + delta_ids: List[str] = [] + + +class VaultService: + """Thread-safe wrapper around vault operations.""" + + def __init__(self, manager: PasswordManager) -> None: + self._manager = manager + self._lock = Lock() + + def export_vault(self, req: VaultExportRequest) -> VaultExportResponse: + """Export the vault to ``req.path``.""" + + with self._lock: + self._manager.handle_export_database(req.path) + return VaultExportResponse(path=req.path) + + def import_vault(self, req: VaultImportRequest) -> None: + """Import the vault from ``req.path`` and sync.""" + + with self._lock: + self._manager.handle_import_database(req.path) + self._manager.sync_vault() + + def change_password(self, req: ChangePasswordRequest) -> None: + """Change the master password.""" + + with self._lock: + self._manager.change_password(req.old_password, req.new_password) + + def unlock(self, req: UnlockRequest) -> UnlockResponse: + """Unlock the vault and return the duration.""" + + with self._lock: + duration = self._manager.unlock_vault(req.password) + return UnlockResponse(duration=duration) + + def lock(self) -> None: + """Lock the vault and clear sensitive data.""" + + with self._lock: + self._manager.lock_vault() + + def backup_parent_seed(self, req: BackupParentSeedRequest) -> None: + """Backup and reveal the parent seed.""" + + with self._lock: + self._manager.handle_backup_reveal_parent_seed(req.path) + + def stats(self) -> Dict: + """Return statistics about the current profile.""" + + with self._lock: + return self._manager.get_profile_stats() + + +class ProfileService: + """Thread-safe wrapper around profile management operations.""" + + def __init__(self, manager: PasswordManager) -> None: + self._manager = manager + self._lock = Lock() + + def list_profiles(self) -> List[str]: + """List available seed profiles.""" + + with self._lock: + return list(self._manager.fingerprint_manager.list_fingerprints()) + + def add_profile(self) -> Optional[str]: + """Create a new seed profile and return its fingerprint if available.""" + + with self._lock: + self._manager.add_new_fingerprint() + return getattr( + self._manager.fingerprint_manager, "current_fingerprint", None + ) + + def remove_profile(self, req: ProfileRemoveRequest) -> None: + """Remove the specified seed profile.""" + + with self._lock: + self._manager.fingerprint_manager.remove_fingerprint(req.fingerprint) + + def switch_profile(self, req: ProfileSwitchRequest) -> None: + """Switch to ``req.fingerprint``.""" + + with self._lock: + self._manager.select_fingerprint(req.fingerprint) + + +class SyncService: + """Thread-safe wrapper around vault synchronization.""" + + def __init__(self, manager: PasswordManager) -> None: + self._manager = manager + self._lock = Lock() + + def sync(self) -> Optional[SyncResponse]: + """Publish the vault to Nostr and return event info.""" + + with self._lock: + result = self._manager.sync_vault() + if not result: + return None + return SyncResponse(**result) + + def start_background_sync(self) -> None: + """Begin background synchronization if possible.""" + + with self._lock: + self._manager.start_background_sync() + + def start_background_vault_sync(self, summary: Optional[str] = None) -> None: + """Publish the vault in a background thread.""" + + with self._lock: + self._manager.start_background_vault_sync(summary) From 6c54d4d8e31e97643e376824bec3f26c60309f42 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 20:21:23 -0400 Subject: [PATCH 05/75] Convert vault sync to async --- src/seedpass/core/manager.py | 59 ++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 5ef90892..e42c8f75 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -1101,18 +1101,18 @@ class PasswordManager: print(colored(f"Error: Failed to initialize managers: {e}", "red")) sys.exit(1) - def sync_index_from_nostr(self) -> None: + async def sync_index_from_nostr_async(self) -> None: """Always fetch the latest vault data from Nostr and update the local index.""" start = time.perf_counter() try: - result = asyncio.run(self.nostr_client.fetch_latest_snapshot()) + result = await self.nostr_client.fetch_latest_snapshot() if not result: return manifest, chunks = result encrypted = gzip.decompress(b"".join(chunks)) if manifest.delta_since: version = int(manifest.delta_since) - deltas = asyncio.run(self.nostr_client.fetch_deltas_since(version)) + deltas = await self.nostr_client.fetch_deltas_since(version) if deltas: encrypted = deltas[-1] current = self.vault.get_encrypted_index() @@ -1128,18 +1128,19 @@ class PasswordManager: duration = time.perf_counter() - start logger.info("sync_index_from_nostr completed in %.2f seconds", duration) + def sync_index_from_nostr(self) -> None: + asyncio.run(self.sync_index_from_nostr_async()) + def start_background_sync(self) -> None: """Launch a thread to synchronize the vault without blocking the UI.""" if getattr(self, "offline_mode", False): return - if ( - hasattr(self, "_sync_thread") - and self._sync_thread - and self._sync_thread.is_alive() + if getattr(self, "_sync_task", None) and not getattr( + self._sync_task, "done", True ): return - def _worker() -> None: + async def _worker() -> None: try: if hasattr(self, "nostr_client") and hasattr(self, "vault"): self.attempt_initial_sync() @@ -1148,8 +1149,12 @@ class PasswordManager: except Exception as exc: logger.warning(f"Background sync failed: {exc}") - self._sync_thread = threading.Thread(target=_worker, daemon=True) - self._sync_thread.start() + try: + loop = asyncio.get_running_loop() + except RuntimeError: + threading.Thread(target=lambda: asyncio.run(_worker()), daemon=True).start() + else: + self._sync_task = asyncio.create_task(_worker()) def start_background_relay_check(self) -> None: """Check relay health in a background thread.""" @@ -1185,13 +1190,18 @@ class PasswordManager: def _worker() -> None: try: - self.sync_vault(alt_summary=alt_summary) + asyncio.run(self.sync_vault_async(alt_summary=alt_summary)) except Exception as exc: logging.error(f"Background vault sync failed: {exc}", exc_info=True) - threading.Thread(target=_worker, daemon=True).start() + try: + loop = asyncio.get_running_loop() + except RuntimeError: + threading.Thread(target=_worker, daemon=True).start() + else: + asyncio.create_task(self.sync_vault_async(alt_summary=alt_summary)) - def attempt_initial_sync(self) -> bool: + async def attempt_initial_sync_async(self) -> bool: """Attempt to download the initial vault snapshot from Nostr. Returns ``True`` if the snapshot was successfully downloaded and the @@ -1205,13 +1215,13 @@ class PasswordManager: have_data = False start = time.perf_counter() try: - result = asyncio.run(self.nostr_client.fetch_latest_snapshot()) + result = await self.nostr_client.fetch_latest_snapshot() if result: manifest, chunks = result encrypted = gzip.decompress(b"".join(chunks)) if manifest.delta_since: version = int(manifest.delta_since) - deltas = asyncio.run(self.nostr_client.fetch_deltas_since(version)) + deltas = await self.nostr_client.fetch_deltas_since(version) if deltas: encrypted = deltas[-1] success = self.vault.decrypt_and_save_index_from_nostr( @@ -1229,17 +1239,23 @@ class PasswordManager: return have_data + def attempt_initial_sync(self) -> bool: + return asyncio.run(self.attempt_initial_sync_async()) + def sync_index_from_nostr_if_missing(self) -> None: """Retrieve the password database from Nostr if it doesn't exist locally. If no valid data is found or decryption fails, initialize a fresh local database and publish it to Nostr. """ - success = self.attempt_initial_sync() + asyncio.run(self.sync_index_from_nostr_if_missing_async()) + + async def sync_index_from_nostr_if_missing_async(self) -> None: + success = await self.attempt_initial_sync_async() if not success: self.vault.save_index({"schema_version": LATEST_VERSION, "entries": {}}) try: - self.sync_vault() + await self.sync_vault_async() except Exception as exc: # pragma: no cover - best effort logger.warning(f"Unable to publish fresh database: {exc}") @@ -3532,7 +3548,7 @@ class PasswordManager: # Re-raise the exception to inform the calling function of the failure raise - def sync_vault( + async def sync_vault_async( self, alt_summary: str | None = None ) -> dict[str, list[str] | str] | None: """Publish the current vault contents to Nostr and return event IDs.""" @@ -3547,7 +3563,7 @@ class PasswordManager: event_id = None if callable(pub_snap): if asyncio.iscoroutinefunction(pub_snap): - manifest, event_id = asyncio.run(pub_snap(encrypted)) + manifest, event_id = await pub_snap(encrypted) else: manifest, event_id = pub_snap(encrypted) else: @@ -3569,6 +3585,11 @@ class PasswordManager: logging.error(f"Failed to sync vault: {e}", exc_info=True) return None + def sync_vault( + self, alt_summary: str | None = None + ) -> dict[str, list[str] | str] | None: + return asyncio.run(self.sync_vault_async(alt_summary=alt_summary)) + def backup_database(self) -> None: """ Creates a backup of the encrypted JSON index file. From 5019c99c11320bd67d54433300b23050586ea481 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 20:42:23 -0400 Subject: [PATCH 06/75] Refactor CLI to use service layer --- src/seedpass/cli.py | 197 +++++++++++------------ src/seedpass/core/api.py | 327 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 414 insertions(+), 110 deletions(-) diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 07da8e58..309b3ace 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -10,6 +10,10 @@ from seedpass.core.api import ( VaultService, ProfileService, SyncService, + EntryService, + ConfigService, + UtilityService, + NostrService, VaultExportRequest, VaultImportRequest, ChangePasswordRequest, @@ -73,6 +77,26 @@ def _get_services( return VaultService(pm), ProfileService(pm), SyncService(pm) +def _get_entry_service(ctx: typer.Context) -> EntryService: + pm = _get_pm(ctx) + return EntryService(pm) + + +def _get_config_service(ctx: typer.Context) -> ConfigService: + pm = _get_pm(ctx) + return ConfigService(pm) + + +def _get_util_service(ctx: typer.Context) -> UtilityService: + pm = _get_pm(ctx) + return UtilityService(pm) + + +def _get_nostr_service(ctx: typer.Context) -> NostrService: + pm = _get_pm(ctx) + return NostrService(pm) + + @app.callback(invoke_without_command=True) def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None: """SeedPass CLI entry point. @@ -95,8 +119,8 @@ def entry_list( archived: bool = typer.Option(False, "--archived", help="Include archived"), ) -> None: """List entries in the vault.""" - pm = _get_pm(ctx) - entries = pm.entry_manager.list_entries( + service = _get_entry_service(ctx) + entries = service.list_entries( sort_by=sort, filter_kind=kind, include_archived=archived ) for idx, label, username, url, is_archived in entries: @@ -113,8 +137,8 @@ def entry_list( @entry_app.command("search") def entry_search(ctx: typer.Context, query: str) -> None: """Search entries.""" - pm = _get_pm(ctx) - results = pm.entry_manager.search_entries(query) + service = _get_entry_service(ctx) + results = service.search_entries(query) if not results: typer.echo("No matching entries found") return @@ -130,8 +154,8 @@ def entry_search(ctx: typer.Context, query: str) -> None: @entry_app.command("get") def entry_get(ctx: typer.Context, query: str) -> None: """Retrieve a single entry's secret.""" - pm = _get_pm(ctx) - matches = pm.entry_manager.search_entries(query) + service = _get_entry_service(ctx) + matches = service.search_entries(query) if len(matches) == 0: typer.echo("No matching entries found") raise typer.Exit(code=1) @@ -145,14 +169,14 @@ def entry_get(ctx: typer.Context, query: str) -> None: raise typer.Exit(code=1) index = matches[0][0] - entry = pm.entry_manager.retrieve_entry(index) + entry = service.retrieve_entry(index) etype = entry.get("type", entry.get("kind")) if etype == EntryType.PASSWORD.value: length = int(entry.get("length", 12)) - password = pm.password_generator.generate_password(length, index) + password = service.generate_password(length, index) typer.echo(password) elif etype == EntryType.TOTP.value: - code = pm.entry_manager.get_totp_code(index, pm.parent_seed) + code = service.get_totp_code(index) typer.echo(code) else: typer.echo("Unsupported entry type") @@ -168,10 +192,9 @@ def entry_add( url: Optional[str] = typer.Option(None, "--url"), ) -> None: """Add a new password entry and output its index.""" - pm = _get_pm(ctx) - index = pm.entry_manager.add_entry(label, length, username, url) + service = _get_entry_service(ctx) + index = service.add_entry(label, length, username, url) typer.echo(str(index)) - pm.sync_vault() @entry_app.command("add-totp") @@ -184,17 +207,15 @@ def entry_add_totp( digits: int = typer.Option(6, "--digits", help="Number of TOTP digits"), ) -> None: """Add a TOTP entry and output the otpauth URI.""" - pm = _get_pm(ctx) - uri = pm.entry_manager.add_totp( + service = _get_entry_service(ctx) + uri = service.add_totp( label, - pm.parent_seed, index=index, secret=secret, period=period, digits=digits, ) typer.echo(uri) - pm.sync_vault() @entry_app.command("add-ssh") @@ -205,15 +226,13 @@ def entry_add_ssh( notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add an SSH key entry and output its index.""" - pm = _get_pm(ctx) - idx = pm.entry_manager.add_ssh_key( + service = _get_entry_service(ctx) + idx = service.add_ssh_key( label, - pm.parent_seed, index=index, notes=notes, ) typer.echo(str(idx)) - pm.sync_vault() @entry_app.command("add-pgp") @@ -226,17 +245,15 @@ def entry_add_pgp( notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add a PGP key entry and output its index.""" - pm = _get_pm(ctx) - idx = pm.entry_manager.add_pgp_key( + service = _get_entry_service(ctx) + idx = service.add_pgp_key( label, - pm.parent_seed, index=index, key_type=key_type, user_id=user_id, notes=notes, ) typer.echo(str(idx)) - pm.sync_vault() @entry_app.command("add-nostr") @@ -247,14 +264,13 @@ def entry_add_nostr( notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add a Nostr key entry and output its index.""" - pm = _get_pm(ctx) - idx = pm.entry_manager.add_nostr_key( + service = _get_entry_service(ctx) + idx = service.add_nostr_key( label, index=index, notes=notes, ) typer.echo(str(idx)) - pm.sync_vault() @entry_app.command("add-seed") @@ -266,16 +282,14 @@ def entry_add_seed( notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add a derived seed phrase entry and output its index.""" - pm = _get_pm(ctx) - idx = pm.entry_manager.add_seed( + service = _get_entry_service(ctx) + idx = service.add_seed( label, - pm.parent_seed, index=index, - words_num=words, + words=words, notes=notes, ) typer.echo(str(idx)) - pm.sync_vault() @entry_app.command("add-key-value") @@ -286,10 +300,9 @@ def entry_add_key_value( notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add a key/value entry and output its index.""" - pm = _get_pm(ctx) - idx = pm.entry_manager.add_key_value(label, value, notes=notes) + service = _get_entry_service(ctx) + idx = service.add_key_value(label, value, notes=notes) typer.echo(str(idx)) - pm.sync_vault() @entry_app.command("add-managed-account") @@ -300,15 +313,13 @@ def entry_add_managed_account( notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add a managed account seed entry and output its index.""" - pm = _get_pm(ctx) - idx = pm.entry_manager.add_managed_account( + service = _get_entry_service(ctx) + idx = service.add_managed_account( label, - pm.parent_seed, index=index, notes=notes, ) typer.echo(str(idx)) - pm.sync_vault() @entry_app.command("modify") @@ -326,9 +337,9 @@ def entry_modify( value: Optional[str] = typer.Option(None, "--value", help="New value"), ) -> None: """Modify an existing entry.""" - pm = _get_pm(ctx) + service = _get_entry_service(ctx) try: - pm.entry_manager.modify_entry( + service.modify_entry( entry_id, username=username, url=url, @@ -341,32 +352,29 @@ def entry_modify( except ValueError as e: typer.echo(str(e)) raise typer.Exit(code=1) - pm.sync_vault() @entry_app.command("archive") def entry_archive(ctx: typer.Context, entry_id: int) -> None: """Archive an entry.""" - pm = _get_pm(ctx) - pm.entry_manager.archive_entry(entry_id) + service = _get_entry_service(ctx) + service.archive_entry(entry_id) typer.echo(str(entry_id)) - pm.sync_vault() @entry_app.command("unarchive") def entry_unarchive(ctx: typer.Context, entry_id: int) -> None: """Restore an archived entry.""" - pm = _get_pm(ctx) - pm.entry_manager.restore_entry(entry_id) + service = _get_entry_service(ctx) + service.restore_entry(entry_id) typer.echo(str(entry_id)) - pm.sync_vault() @entry_app.command("totp-codes") def entry_totp_codes(ctx: typer.Context) -> None: """Display all current TOTP codes.""" - pm = _get_pm(ctx) - pm.handle_display_totp_codes() + service = _get_entry_service(ctx) + service.display_totp_codes() @entry_app.command("export-totp") @@ -374,8 +382,8 @@ def entry_export_totp( ctx: typer.Context, file: str = typer.Option(..., help="Output file") ) -> None: """Export all TOTP secrets to a JSON file.""" - pm = _get_pm(ctx) - data = pm.entry_manager.export_totp_entries(pm.parent_seed) + service = _get_entry_service(ctx) + data = service.export_totp_entries() Path(file).write_text(json.dumps(data, indent=2)) typer.echo(str(file)) @@ -478,16 +486,16 @@ def nostr_sync(ctx: typer.Context) -> None: @nostr_app.command("get-pubkey") def nostr_get_pubkey(ctx: typer.Context) -> None: """Display the active profile's npub.""" - pm = _get_pm(ctx) - npub = pm.nostr_client.key_manager.get_npub() + service = _get_nostr_service(ctx) + npub = service.get_pubkey() typer.echo(npub) @config_app.command("get") def config_get(ctx: typer.Context, key: str) -> None: """Get a configuration value.""" - pm = _get_pm(ctx) - value = pm.config_manager.load_config(require_pin=False).get(key) + service = _get_config_service(ctx) + value = service.get(key) if value is None: typer.echo("Key not found") else: @@ -497,43 +505,18 @@ def config_get(ctx: typer.Context, key: str) -> None: @config_app.command("set") def config_set(ctx: typer.Context, key: str, value: str) -> None: """Set a configuration value.""" - pm = _get_pm(ctx) - cfg = pm.config_manager - - mapping = { - "inactivity_timeout": lambda v: cfg.set_inactivity_timeout(float(v)), - "secret_mode_enabled": lambda v: cfg.set_secret_mode_enabled( - v.lower() in ("1", "true", "yes", "y", "on") - ), - "clipboard_clear_delay": lambda v: cfg.set_clipboard_clear_delay(int(v)), - "additional_backup_path": lambda v: cfg.set_additional_backup_path(v or None), - "relays": lambda v: cfg.set_relays( - [r.strip() for r in v.split(",") if r.strip()], require_pin=False - ), - "kdf_iterations": lambda v: cfg.set_kdf_iterations(int(v)), - "kdf_mode": lambda v: cfg.set_kdf_mode(v), - "backup_interval": lambda v: cfg.set_backup_interval(float(v)), - "nostr_max_retries": lambda v: cfg.set_nostr_max_retries(int(v)), - "nostr_retry_delay": lambda v: cfg.set_nostr_retry_delay(float(v)), - "min_uppercase": lambda v: cfg.set_min_uppercase(int(v)), - "min_lowercase": lambda v: cfg.set_min_lowercase(int(v)), - "min_digits": lambda v: cfg.set_min_digits(int(v)), - "min_special": lambda v: cfg.set_min_special(int(v)), - "quick_unlock": lambda v: cfg.set_quick_unlock( - v.lower() in ("1", "true", "yes", "y", "on") - ), - "verbose_timing": lambda v: cfg.set_verbose_timing( - v.lower() in ("1", "true", "yes", "y", "on") - ), - } - - action = mapping.get(key) - if action is None: - typer.echo("Unknown key") - raise typer.Exit(code=1) + service = _get_config_service(ctx) try: - action(value) + val = ( + [r.strip() for r in value.split(",") if r.strip()] + if key == "relays" + else value + ) + service.set(key, val) + except KeyError: + typer.echo("Unknown key") + raise typer.Exit(code=1) except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error: {exc}") raise typer.Exit(code=1) @@ -544,11 +527,10 @@ def config_set(ctx: typer.Context, key: str, value: str) -> None: @config_app.command("toggle-secret-mode") def config_toggle_secret_mode(ctx: typer.Context) -> None: """Interactively enable or disable secret mode.""" - pm = _get_pm(ctx) - cfg = pm.config_manager + service = _get_config_service(ctx) try: - enabled = cfg.get_secret_mode_enabled() - delay = cfg.get_clipboard_clear_delay() + enabled = service.get_secret_mode_enabled() + delay = service.get_clipboard_clear_delay() except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error loading settings: {exc}") raise typer.Exit(code=1) @@ -580,10 +562,7 @@ def config_toggle_secret_mode(ctx: typer.Context) -> None: raise typer.Exit(code=1) try: - cfg.set_secret_mode_enabled(enabled) - cfg.set_clipboard_clear_delay(delay) - pm.secret_mode_enabled = enabled - pm.clipboard_clear_delay = delay + service.set_secret_mode(enabled, delay) except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error: {exc}") raise typer.Exit(code=1) @@ -595,10 +574,9 @@ def config_toggle_secret_mode(ctx: typer.Context) -> None: @config_app.command("toggle-offline") def config_toggle_offline(ctx: typer.Context) -> None: """Enable or disable offline mode.""" - pm = _get_pm(ctx) - cfg = pm.config_manager + service = _get_config_service(ctx) try: - enabled = cfg.get_offline_mode() + enabled = service.get_offline_mode() except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error loading settings: {exc}") raise typer.Exit(code=1) @@ -617,8 +595,7 @@ def config_toggle_offline(ctx: typer.Context) -> None: enabled = False try: - cfg.set_offline_mode(enabled) - pm.offline_mode = enabled + service.set_offline_mode(enabled) except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error: {exc}") raise typer.Exit(code=1) @@ -659,23 +636,23 @@ def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None: @util_app.command("generate-password") def generate_password(ctx: typer.Context, length: int = 24) -> None: """Generate a strong password.""" - pm = _get_pm(ctx) - password = pm.password_generator.generate_password(length) + service = _get_util_service(ctx) + password = service.generate_password(length) typer.echo(password) @util_app.command("verify-checksum") def verify_checksum(ctx: typer.Context) -> None: """Verify the SeedPass script checksum.""" - pm = _get_pm(ctx) - pm.handle_verify_checksum() + service = _get_util_service(ctx) + service.verify_checksum() @util_app.command("update-checksum") def update_checksum(ctx: typer.Context) -> None: """Regenerate the script checksum file.""" - pm = _get_pm(ctx) - pm.handle_update_script_checksum() + service = _get_util_service(ctx) + service.update_checksum() @api_app.command("start") diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py index 03609a8a..9f6671c0 100644 --- a/src/seedpass/core/api.py +++ b/src/seedpass/core/api.py @@ -194,3 +194,330 @@ class SyncService: with self._lock: self._manager.start_background_vault_sync(summary) + + +class EntryService: + """Thread-safe wrapper around entry operations.""" + + def __init__(self, manager: PasswordManager) -> None: + self._manager = manager + self._lock = Lock() + + def list_entries( + self, + sort_by: str = "index", + filter_kind: str | None = None, + include_archived: bool = False, + ): + with self._lock: + return self._manager.entry_manager.list_entries( + sort_by=sort_by, + filter_kind=filter_kind, + include_archived=include_archived, + ) + + def search_entries(self, query: str): + with self._lock: + return self._manager.entry_manager.search_entries(query) + + def retrieve_entry(self, entry_id: int): + with self._lock: + return self._manager.entry_manager.retrieve_entry(entry_id) + + def generate_password(self, length: int, index: int) -> str: + with self._lock: + return self._manager.password_generator.generate_password(length, index) + + def get_totp_code(self, entry_id: int) -> str: + with self._lock: + return self._manager.entry_manager.get_totp_code( + entry_id, self._manager.parent_seed + ) + + def add_entry( + self, + label: str, + length: int, + username: str | None = None, + url: str | None = None, + ) -> int: + with self._lock: + idx = self._manager.entry_manager.add_entry(label, length, username, url) + self._manager.sync_vault() + return idx + + def add_totp( + self, + label: str, + *, + index: int | None = None, + secret: str | None = None, + period: int = 30, + digits: int = 6, + ) -> str: + with self._lock: + uri = self._manager.entry_manager.add_totp( + label, + self._manager.parent_seed, + index=index, + secret=secret, + period=period, + digits=digits, + ) + self._manager.sync_vault() + return uri + + def add_ssh_key( + self, + label: str, + *, + index: int | None = None, + notes: str = "", + ) -> int: + with self._lock: + idx = self._manager.entry_manager.add_ssh_key( + label, + self._manager.parent_seed, + index=index, + notes=notes, + ) + self._manager.sync_vault() + return idx + + def add_pgp_key( + self, + label: str, + *, + index: int | None = None, + key_type: str = "ed25519", + user_id: str = "", + notes: str = "", + ) -> int: + with self._lock: + idx = self._manager.entry_manager.add_pgp_key( + label, + self._manager.parent_seed, + index=index, + key_type=key_type, + user_id=user_id, + notes=notes, + ) + self._manager.sync_vault() + return idx + + def add_nostr_key( + self, + label: str, + *, + index: int | None = None, + notes: str = "", + ) -> int: + with self._lock: + idx = self._manager.entry_manager.add_nostr_key( + label, + index=index, + notes=notes, + ) + self._manager.sync_vault() + return idx + + def add_seed( + self, + label: str, + *, + index: int | None = None, + words: int = 24, + notes: str = "", + ) -> int: + with self._lock: + idx = self._manager.entry_manager.add_seed( + label, + self._manager.parent_seed, + index=index, + words_num=words, + notes=notes, + ) + self._manager.sync_vault() + return idx + + def add_key_value(self, label: str, value: str, *, notes: str = "") -> int: + with self._lock: + idx = self._manager.entry_manager.add_key_value(label, value, notes=notes) + self._manager.sync_vault() + return idx + + def add_managed_account( + self, + label: str, + *, + index: int | None = None, + notes: str = "", + ) -> int: + with self._lock: + idx = self._manager.entry_manager.add_managed_account( + label, + self._manager.parent_seed, + index=index, + notes=notes, + ) + self._manager.sync_vault() + return idx + + def modify_entry( + self, + entry_id: int, + *, + username: str | None = None, + url: str | None = None, + notes: str | None = None, + label: str | None = None, + period: int | None = None, + digits: int | None = None, + value: str | None = None, + ) -> None: + with self._lock: + self._manager.entry_manager.modify_entry( + entry_id, + username=username, + url=url, + notes=notes, + label=label, + period=period, + digits=digits, + value=value, + ) + self._manager.sync_vault() + + def archive_entry(self, entry_id: int) -> None: + with self._lock: + self._manager.entry_manager.archive_entry(entry_id) + self._manager.sync_vault() + + def restore_entry(self, entry_id: int) -> None: + with self._lock: + self._manager.entry_manager.restore_entry(entry_id) + self._manager.sync_vault() + + def export_totp_entries(self) -> dict: + with self._lock: + return self._manager.entry_manager.export_totp_entries( + self._manager.parent_seed + ) + + def display_totp_codes(self) -> None: + with self._lock: + self._manager.handle_display_totp_codes() + + +class ConfigService: + """Thread-safe wrapper around configuration access.""" + + def __init__(self, manager: PasswordManager) -> None: + self._manager = manager + self._lock = Lock() + + def get(self, key: str): + with self._lock: + return self._manager.config_manager.load_config(require_pin=False).get(key) + + def set(self, key: str, value: str) -> None: + cfg = self._manager.config_manager + mapping = { + "inactivity_timeout": ("set_inactivity_timeout", float), + "secret_mode_enabled": ( + "set_secret_mode_enabled", + lambda v: v.lower() in ("1", "true", "yes", "y", "on"), + ), + "clipboard_clear_delay": ("set_clipboard_clear_delay", int), + "additional_backup_path": ( + "set_additional_backup_path", + lambda v: v or None, + ), + "relays": ("set_relays", lambda v: (v, {"require_pin": False})), + "kdf_iterations": ("set_kdf_iterations", int), + "kdf_mode": ("set_kdf_mode", lambda v: v), + "backup_interval": ("set_backup_interval", float), + "nostr_max_retries": ("set_nostr_max_retries", int), + "nostr_retry_delay": ("set_nostr_retry_delay", float), + "min_uppercase": ("set_min_uppercase", int), + "min_lowercase": ("set_min_lowercase", int), + "min_digits": ("set_min_digits", int), + "min_special": ("set_min_special", int), + "quick_unlock": ( + "set_quick_unlock", + lambda v: v.lower() in ("1", "true", "yes", "y", "on"), + ), + } + entry = mapping.get(key) + if entry is None: + raise KeyError(key) + method_name, conv = entry + with self._lock: + result = conv(value) + if ( + isinstance(result, tuple) + and len(result) == 2 + and isinstance(result[1], dict) + ): + arg, kwargs = result + getattr(cfg, method_name)(arg, **kwargs) + else: + getattr(cfg, method_name)(result) + + def get_secret_mode_enabled(self) -> bool: + with self._lock: + return self._manager.config_manager.get_secret_mode_enabled() + + def get_clipboard_clear_delay(self) -> int: + with self._lock: + return self._manager.config_manager.get_clipboard_clear_delay() + + def set_secret_mode(self, enabled: bool, delay: int) -> None: + with self._lock: + cfg = self._manager.config_manager + cfg.set_secret_mode_enabled(enabled) + cfg.set_clipboard_clear_delay(delay) + self._manager.secret_mode_enabled = enabled + self._manager.clipboard_clear_delay = delay + + def get_offline_mode(self) -> bool: + with self._lock: + return self._manager.config_manager.get_offline_mode() + + def set_offline_mode(self, enabled: bool) -> None: + with self._lock: + cfg = self._manager.config_manager + cfg.set_offline_mode(enabled) + self._manager.offline_mode = enabled + + +class UtilityService: + """Miscellaneous helper operations.""" + + def __init__(self, manager: PasswordManager) -> None: + self._manager = manager + self._lock = Lock() + + def generate_password(self, length: int) -> str: + with self._lock: + return self._manager.password_generator.generate_password(length) + + def verify_checksum(self) -> None: + with self._lock: + self._manager.handle_verify_checksum() + + def update_checksum(self) -> None: + with self._lock: + self._manager.handle_update_script_checksum() + + +class NostrService: + """Nostr related helper methods.""" + + def __init__(self, manager: PasswordManager) -> None: + self._manager = manager + self._lock = Lock() + + def get_pubkey(self) -> str: + with self._lock: + return self._manager.nostr_client.key_manager.get_npub() From 6c22e28512151be5f36e8cfb0a2a85f98320eb8f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 21:09:12 -0400 Subject: [PATCH 07/75] Add basic Toga GUI --- src/requirements.txt | 1 + src/runtime_requirements.txt | 1 + src/seedpass_gui/__init__.py | 11 ++ src/seedpass_gui/app.py | 199 +++++++++++++++++++++++++++++++++++ 4 files changed, 212 insertions(+) create mode 100644 src/seedpass_gui/__init__.py create mode 100644 src/seedpass_gui/app.py diff --git a/src/requirements.txt b/src/requirements.txt index f3951b04..c0e094c9 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -32,3 +32,4 @@ requests>=2.32 python-multipart orjson argon2-cffi +toga-core>=0.5.2 diff --git a/src/runtime_requirements.txt b/src/runtime_requirements.txt index 38cf46eb..1bde15f8 100644 --- a/src/runtime_requirements.txt +++ b/src/runtime_requirements.txt @@ -27,3 +27,4 @@ requests>=2.32 python-multipart orjson argon2-cffi +toga-core>=0.5.2 diff --git a/src/seedpass_gui/__init__.py b/src/seedpass_gui/__init__.py new file mode 100644 index 00000000..f473abca --- /dev/null +++ b/src/seedpass_gui/__init__.py @@ -0,0 +1,11 @@ +"""Graphical user interface for SeedPass.""" + +from .app import SeedPassApp + + +def main() -> None: + """Launch the GUI application.""" + SeedPassApp().main_loop() + + +__all__ = ["SeedPassApp", "main"] diff --git a/src/seedpass_gui/app.py b/src/seedpass_gui/app.py new file mode 100644 index 00000000..1ef0e2de --- /dev/null +++ b/src/seedpass_gui/app.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import toga +from toga.style import Pack +from toga.style.pack import COLUMN, ROW + +from seedpass.core.manager import PasswordManager +from seedpass.core.api import ( + VaultService, + EntryService, + UnlockRequest, +) + + +class LockScreenWindow(toga.Window): + """Window prompting for the master password.""" + + def __init__( + self, app: SeedPassApp, vault: VaultService, entries: EntryService + ) -> None: + super().__init__("Unlock Vault") + self.app = app + self.vault = vault + self.entries = entries + + self.password_input = toga.PasswordInput(style=Pack(flex=1)) + self.message = toga.Label("", style=Pack(color="red")) + unlock_button = toga.Button( + "Unlock", on_press=self.handle_unlock, style=Pack(padding_top=10) + ) + + box = toga.Box(style=Pack(direction=COLUMN, padding=20)) + box.add(toga.Label("Master Password:")) + box.add(self.password_input) + box.add(unlock_button) + box.add(self.message) + self.content = box + + def handle_unlock(self, widget: toga.Widget) -> None: + password = self.password_input.value or "" + try: + self.vault.unlock(UnlockRequest(password=password)) + except Exception as exc: # pragma: no cover - GUI error handling + self.message.text = str(exc) + return + main = MainWindow(self.app, self.vault, self.entries) + self.app.main_window = main + main.show() + self.close() + + +class MainWindow(toga.Window): + """Main application window showing vault entries.""" + + def __init__( + self, app: SeedPassApp, vault: VaultService, entries: EntryService + ) -> None: + super().__init__("SeedPass") + self.app = app + self.vault = vault + self.entries = entries + + self.table = toga.Table( + headings=["ID", "Label", "Username", "URL"], style=Pack(flex=1) + ) + + add_button = toga.Button("Add", on_press=self.add_entry) + edit_button = toga.Button("Edit", on_press=self.edit_entry) + search_button = toga.Button("Search", on_press=self.search_entries) + + button_box = toga.Box(style=Pack(direction=ROW, padding_top=5)) + button_box.add(add_button) + button_box.add(edit_button) + button_box.add(search_button) + + box = toga.Box(style=Pack(direction=COLUMN, padding=10)) + box.add(self.table) + box.add(button_box) + self.content = box + + self.refresh_entries() + + def refresh_entries(self) -> None: + self.table.data = [] + for idx, label, username, url, _arch in self.entries.list_entries(): + self.table.data.append((idx, label, username or "", url or "")) + + # --- Button handlers ------------------------------------------------- + def add_entry(self, widget: toga.Widget) -> None: + dlg = EntryDialog(self, None) + dlg.show() + + def edit_entry(self, widget: toga.Widget) -> None: + if self.table.selection is None: + return + entry_id = int(self.table.selection[0]) + dlg = EntryDialog(self, entry_id) + dlg.show() + + def search_entries(self, widget: toga.Widget) -> None: + dlg = SearchDialog(self) + dlg.show() + + +class EntryDialog(toga.Window): + """Dialog for adding or editing an entry.""" + + def __init__(self, main: MainWindow, entry_id: int | None) -> None: + title = "Add Entry" if entry_id is None else "Edit Entry" + super().__init__(title) + self.main = main + self.entry_id = entry_id + + self.label_input = toga.TextInput(style=Pack(flex=1)) + self.username_input = toga.TextInput(style=Pack(flex=1)) + self.url_input = toga.TextInput(style=Pack(flex=1)) + self.length_input = toga.NumberInput( + min_value=8, max_value=128, style=Pack(width=80), value=16 + ) + + save_button = toga.Button( + "Save", on_press=self.save, style=Pack(padding_top=10) + ) + + box = toga.Box(style=Pack(direction=COLUMN, padding=20)) + box.add(toga.Label("Label")) + box.add(self.label_input) + box.add(toga.Label("Username")) + box.add(self.username_input) + box.add(toga.Label("URL")) + box.add(self.url_input) + box.add(toga.Label("Length")) + box.add(self.length_input) + box.add(save_button) + self.content = box + + if entry_id is not None: + entry = self.main.entries.retrieve_entry(entry_id) + if entry: + self.label_input.value = entry.get("label", "") + self.username_input.value = entry.get("username", "") or "" + self.url_input.value = entry.get("url", "") or "" + self.length_input.value = entry.get("length", 16) + + def save(self, widget: toga.Widget) -> None: + label = self.label_input.value or "" + username = self.username_input.value or None + url = self.url_input.value or None + length = int(self.length_input.value or 16) + + if self.entry_id is None: + self.main.entries.add_entry(label, length, username=username, url=url) + else: + self.main.entries.modify_entry( + self.entry_id, username=username, url=url, label=label + ) + self.main.refresh_entries() + self.close() + + +class SearchDialog(toga.Window): + """Dialog for searching entries.""" + + def __init__(self, main: MainWindow) -> None: + super().__init__("Search Entries") + self.main = main + self.query_input = toga.TextInput(style=Pack(flex=1)) + search_button = toga.Button( + "Search", on_press=self.do_search, style=Pack(padding_top=10) + ) + box = toga.Box(style=Pack(direction=COLUMN, padding=20)) + box.add(toga.Label("Query")) + box.add(self.query_input) + box.add(search_button) + self.content = box + + def do_search(self, widget: toga.Widget) -> None: + query = self.query_input.value or "" + results = self.main.entries.search_entries(query) + self.main.table.data = [] + for idx, label, username, url, _arch in results: + self.main.table.data.append((idx, label, username or "", url or "")) + self.close() + + +def build() -> SeedPassApp: + return SeedPassApp() + + +class SeedPassApp(toga.App): + def startup(self) -> None: # pragma: no cover - GUI bootstrap + pm = PasswordManager() + self.vault_service = VaultService(pm) + self.entry_service = EntryService(pm) + self.lock_window = LockScreenWindow( + self, self.vault_service, self.entry_service + ) + self.main_window = None + self.lock_window.show() From fac31cd99f81c47ef9cff1854aa05e849746b99b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 21:21:05 -0400 Subject: [PATCH 08/75] Add GUI runner and packaging docs --- .../01-getting-started/05-briefcase.md | 20 +++++++++++++++++++ pyproject.toml | 1 + src/run_gui.py | 4 ++++ src/seedpass_gui/app.py | 5 +++++ 4 files changed, 30 insertions(+) create mode 100644 docs/docs/content/01-getting-started/05-briefcase.md create mode 100644 src/run_gui.py diff --git a/docs/docs/content/01-getting-started/05-briefcase.md b/docs/docs/content/01-getting-started/05-briefcase.md new file mode 100644 index 00000000..c461f448 --- /dev/null +++ b/docs/docs/content/01-getting-started/05-briefcase.md @@ -0,0 +1,20 @@ +# Packaging the GUI with Briefcase + +This project uses [BeeWare's Briefcase](https://beeware.org) to generate +platform‑native installers. Once your development environment is set up, +package the GUI by running the following commands from the repository root: + +```bash +# Create the application scaffold for your platform +briefcase create + +# Compile dependencies and produce a distributable bundle +briefcase build + +# Run the packaged application +briefcase run +``` + +`briefcase create` only needs to be executed once per platform. After the +initial creation step you can repeatedly run `briefcase build` followed by +`briefcase run` to test your packaged application on Windows, macOS or Linux. diff --git a/pyproject.toml b/pyproject.toml index 4165dd2f..866aa676 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" [project.scripts] seedpass = "seedpass.cli:app" +seedpass-gui = "seedpass_gui.app:main" [tool.mypy] python_version = "3.11" diff --git a/src/run_gui.py b/src/run_gui.py new file mode 100644 index 00000000..9f757c71 --- /dev/null +++ b/src/run_gui.py @@ -0,0 +1,4 @@ +from seedpass_gui.app import main + +if __name__ == "__main__": + main() diff --git a/src/seedpass_gui/app.py b/src/seedpass_gui/app.py index 1ef0e2de..b3b84cbf 100644 --- a/src/seedpass_gui/app.py +++ b/src/seedpass_gui/app.py @@ -197,3 +197,8 @@ class SeedPassApp(toga.App): ) self.main_window = None self.lock_window.show() + + +def main() -> None: # pragma: no cover - GUI bootstrap + """Run the BeeWare application.""" + SeedPassApp().main_loop() From cd0f9624aea24c72712986cb7515b97ed617af96 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 21:33:06 -0400 Subject: [PATCH 09/75] Add service layer and CLI regression tests --- src/tests/test_cli_core_services.py | 69 ++++++++++++++++++++++++++++ src/tests/test_core_services.py | 71 +++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/tests/test_cli_core_services.py create mode 100644 src/tests/test_core_services.py diff --git a/src/tests/test_cli_core_services.py b/src/tests/test_cli_core_services.py new file mode 100644 index 00000000..45a6d9d6 --- /dev/null +++ b/src/tests/test_cli_core_services.py @@ -0,0 +1,69 @@ +from types import SimpleNamespace + +import typer +from typer.testing import CliRunner + +from seedpass import cli +from seedpass.cli import app + +runner = CliRunner() + + +def test_cli_vault_unlock(monkeypatch): + called = {} + + def unlock_vault(pw): + called["pw"] = pw + return 0.5 + + pm = SimpleNamespace(unlock_vault=unlock_vault, select_fingerprint=lambda fp: None) + monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli.typer, "prompt", lambda *a, **k: "pw") + result = runner.invoke(app, ["vault", "unlock"]) + assert result.exit_code == 0 + assert "Unlocked in" in result.stdout + assert called["pw"] == "pw" + + +def test_cli_entry_add_search_sync(monkeypatch): + calls = {} + + def add_entry(label, length, username=None, url=None): + calls["add"] = (label, length, username, url) + return 1 + + def search_entries(q): + calls["search"] = q + return [(1, "Label", None, None, False)] + + def sync_vault(): + calls["sync"] = True + return {"manifest_id": "m", "chunk_ids": [], "delta_ids": []} + + pm = SimpleNamespace( + entry_manager=SimpleNamespace( + add_entry=add_entry, search_entries=search_entries + ), + sync_vault=sync_vault, + select_fingerprint=lambda fp: None, + ) + monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + + # entry add + result = runner.invoke(app, ["entry", "add", "Label"]) + assert result.exit_code == 0 + assert "1" in result.stdout + assert calls["add"] == ("Label", 12, None, None) + assert calls.get("sync") is True + + # entry search + result = runner.invoke(app, ["entry", "search", "lab"]) + assert result.exit_code == 0 + assert "Label" in result.stdout + assert calls["search"] == "lab" + + # nostr sync + result = runner.invoke(app, ["nostr", "sync"]) + assert result.exit_code == 0 + assert "manifest" in result.stdout.lower() + assert calls.get("sync") is True diff --git a/src/tests/test_core_services.py b/src/tests/test_core_services.py new file mode 100644 index 00000000..d71a859e --- /dev/null +++ b/src/tests/test_core_services.py @@ -0,0 +1,71 @@ +import types +from types import SimpleNamespace + +from seedpass.core.api import VaultService, EntryService, SyncService, UnlockRequest + + +def test_vault_service_unlock(): + called = {} + + def unlock_vault(pw: str) -> float: + called["pw"] = pw + return 0.42 + + pm = SimpleNamespace(unlock_vault=unlock_vault) + service = VaultService(pm) + resp = service.unlock(UnlockRequest(password="secret")) + assert called["pw"] == "secret" + assert resp.duration == 0.42 + + +def test_entry_service_add_entry_and_search(): + called = {} + + def add_entry(label, length, username=None, url=None): + called["add"] = (label, length, username, url) + return 5 + + def search_entries(q): + called["search"] = q + return [(5, "Example", username, url, False)] + + def sync_vault(): + called["sync"] = True + + username = "user" + url = "https://ex.com" + pm = SimpleNamespace( + entry_manager=SimpleNamespace( + add_entry=add_entry, search_entries=search_entries + ), + sync_vault=sync_vault, + ) + service = EntryService(pm) + idx = service.add_entry("Example", 12, username, url) + assert idx == 5 + assert called["add"] == ("Example", 12, username, url) + assert called.get("sync") is True + + results = service.search_entries("ex") + assert results == [(5, "Example", username, url, False)] + assert called["search"] == "ex" + + +def test_sync_service_sync(): + called = {} + + def sync_vault(): + called["sync"] = True + return { + "manifest_id": "m1", + "chunk_ids": ["c1"], + "delta_ids": ["d1"], + } + + pm = SimpleNamespace(sync_vault=sync_vault) + service = SyncService(pm) + resp = service.sync() + assert called["sync"] is True + assert resp.manifest_id == "m1" + assert resp.chunk_ids == ["c1"] + assert resp.delta_ids == ["d1"] From 47ea11b533257390b7454496438e7541ceb1cb6a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 21:50:18 -0400 Subject: [PATCH 10/75] docs: describe architecture --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index 7b39a4d1..1c66e3d0 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No ## Table of Contents - [Features](#features) +- [Architecture Overview](#architecture-overview) - [Prerequisites](#prerequisites) - [Installation](#installation) - [1. Clone the Repository](#1-clone-the-repository) @@ -65,9 +66,39 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - **Relay Management:** List, add, remove or reset configured Nostr relays. - **Offline Mode:** Disable all Nostr communication for local-only operation. + A small on-screen notification area now shows queued messages for 10 seconds before fading. +## Architecture Overview + +SeedPass follows a layered design. The **`seedpass.core`** package exposes the +`PasswordManager` along with service classes (e.g. `VaultService` and +`EntryService`) that implement the main API used across interfaces. +The command line tool in **`seedpass.cli`** is a thin adapter built with Typer +that delegates operations to this API layer. + +The BeeWare desktop interface lives in **`seedpass_gui.app`** and can be +started with either `seedpass-gui` or `python -m seedpass_gui`. It reuses the +same service objects to unlock the vault, list entries and search through them. + +An optional browser extension can communicate with the FastAPI server exposed by +`seedpass.api` to manage entries from within the browser. + +```mermaid +graph TD + core["seedpass.core"] + cli["CLI"] + api["FastAPI server"] + gui["BeeWare GUI"] + ext["Browser Extension"] + + cli --> core + gui --> core + api --> core + ext --> api +``` + ## Prerequisites - **Python 3.8+** (3.11 or 3.12 recommended): Install Python from [python.org](https://www.python.org/downloads/) and be sure to check **"Add Python to PATH"** during setup. Using Python 3.13 is currently discouraged because some dependencies do not ship wheels for it yet, which can cause build failures on Windows unless you install the Visual C++ Build Tools. From 728c4be6e1dc508ede1225584756ac396f95eccd Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 21:57:22 -0400 Subject: [PATCH 11/75] docs: add architecture diagram --- docs/docs/content/index.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index d4178248..a4d072a5 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -16,6 +16,22 @@ This software was not developed by an experienced security expert and should be ✔ Windows 10/11 • macOS 12+ • Any modern Linux SeedPass now uses the `portalocker` library for cross-platform file locking. No WSL or Cygwin required. +```mermaid +graph TD + core(seedpass.core) + cli(CLI/TUI) + gui(BeeWare GUI) + ext(Browser extension) + cli --> core + gui --> core + ext --> core +``` + +SeedPass uses a modular design with a single core library that handles all +security-critical logic. The current CLI/TUI adapter communicates with +`seedpass.core`, and future interfaces like a BeeWare GUI and a browser +extension can hook into the same layer. This architecture keeps the codebase +maintainable while enabling a consistent experience on multiple platforms. ## Table of Contents From 3ff98437509120cc3b2eb97ff920b933a1138010 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 22:03:53 -0400 Subject: [PATCH 12/75] docs: explain BeeWare adapter --- .../01-getting-started/06-gui_adapter.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 docs/docs/content/01-getting-started/06-gui_adapter.md diff --git a/docs/docs/content/01-getting-started/06-gui_adapter.md b/docs/docs/content/01-getting-started/06-gui_adapter.md new file mode 100644 index 00000000..3d9c9b59 --- /dev/null +++ b/docs/docs/content/01-getting-started/06-gui_adapter.md @@ -0,0 +1,77 @@ +# BeeWare GUI Adapter + +SeedPass ships with a proof-of-concept graphical interface built using [BeeWare](https://beeware.org). The GUI interacts with the same core services as the CLI by instantiating wrappers around `PasswordManager`. + +## VaultService and EntryService + +`VaultService` provides thread-safe access to vault operations like exporting, importing, unlocking and locking the vault. `EntryService` exposes methods for listing, searching and modifying entries. Both classes live in `seedpass.core.api` and hold a `PasswordManager` instance protected by a `threading.Lock` to ensure safe concurrent access. + +```python +class VaultService: + """Thread-safe wrapper around vault operations.""" + def __init__(self, manager: PasswordManager) -> None: + self._manager = manager + self._lock = Lock() +``` + +```python +class EntryService: + """Thread-safe wrapper around entry operations.""" + def __init__(self, manager: PasswordManager) -> None: + self._manager = manager + self._lock = Lock() +``` + +## BeeWare Windows + +The GUI defines two main windows in `src/seedpass_gui/app.py`. `LockScreenWindow` prompts for the master password and then opens `MainWindow` to display the vault entries. + +```python +class LockScreenWindow(toga.Window): + """Window prompting for the master password.""" + def __init__(self, app: SeedPassApp, vault: VaultService, entries: EntryService) -> None: + super().__init__("Unlock Vault") + self.app = app + self.vault = vault + self.entries = entries + ... +``` + +```python +class MainWindow(toga.Window): + """Main application window showing vault entries.""" + def __init__(self, app: SeedPassApp, vault: VaultService, entries: EntryService) -> None: + super().__init__("SeedPass") + self.app = app + self.vault = vault + self.entries = entries + ... +``` + +Each window receives the service instances and calls methods such as `vault.unlock()` or `entries.add_entry()` when buttons are pressed. This keeps the UI thin while reusing the core logic. + +## Asynchronous Synchronization + +`PasswordManager` performs network synchronization with Nostr using `asyncio`. Methods like `start_background_vault_sync()` create a coroutine that calls `sync_vault_async()` in a background thread or task without blocking the UI. + +```python +async def sync_vault_async(self, alt_summary: str | None = None) -> dict[str, list[str] | str] | None: + """Publish the current vault contents to Nostr and return event IDs.""" + ... +``` + +```python +def start_background_vault_sync(self, alt_summary: str | None = None) -> None: + if getattr(self, "offline_mode", False): + return + def _worker() -> None: + asyncio.run(self.sync_vault_async(alt_summary=alt_summary)) + try: + loop = asyncio.get_running_loop() + except RuntimeError: + threading.Thread(target=_worker, daemon=True).start() + else: + asyncio.create_task(self.sync_vault_async(alt_summary=alt_summary)) +``` + +This approach ensures synchronization happens asynchronously whether the GUI is running inside or outside an existing event loop. From beb690ba725c4502cdd0c2742b7671f37c190fd5 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 22:11:58 -0400 Subject: [PATCH 13/75] landing: add modular architecture diagram --- landing/index.html | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/landing/index.html b/landing/index.html index 2814e2b9..678c5b93 100644 --- a/landing/index.html +++ b/landing/index.html @@ -84,6 +84,26 @@ flowchart TB

Architecture Overview

 ---
+config:
+  layout: fixed
+  theme: base
+  themeVariables:
+    primaryColor: '#e94a39'
+    primaryBorderColor: '#e94a39'
+    lineColor: '#e94a39'
+  look: classic
+---
+graph TD
+    core(seedpass.core)
+    cli(CLI/TUI)
+    gui(BeeWare GUI)
+    ext(Browser extension)
+    cli --> core
+    gui --> core
+    ext --> core
+                
+
+---
 config:
   layout: fixed
   theme: base

From df0279ac03fdab8b3aae2c2c541f92b30955394c Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Thu, 17 Jul 2025 22:21:09 -0400
Subject: [PATCH 14/75] docs: reference gui adapter and core services

---
 docs/docs/content/01-getting-started/02-api_reference.md | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/docs/docs/content/01-getting-started/02-api_reference.md b/docs/docs/content/01-getting-started/02-api_reference.md
index 5c23bf6d..38ac3b68 100644
--- a/docs/docs/content/01-getting-started/02-api_reference.md
+++ b/docs/docs/content/01-getting-started/02-api_reference.md
@@ -2,6 +2,9 @@
 
 This guide covers how to start the SeedPass API, authenticate requests, and interact with the available endpoints.
 
+**Note:** All UI layers, including the CLI, BeeWare GUI, and future adapters, consume this REST API through service classes in `seedpass.core`. See [docs/gui_adapter.md](docs/gui_adapter.md) for more details on the GUI integration.
+
+
 ## Starting the API
 
 Run `seedpass api start` from your terminal. The command prints a one‑time token used for authentication:

From 474f2d134b95b045794c4908a189e9c79bb1c05c Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Thu, 17 Jul 2025 22:25:34 -0400
Subject: [PATCH 15/75] Add architecture documentation

---
 README.md            |  2 ++
 docs/ARCHITECTURE.md | 41 +++++++++++++++++++++++++++++++++++++++++
 2 files changed, 43 insertions(+)
 create mode 100644 docs/ARCHITECTURE.md

diff --git a/README.md b/README.md
index 1c66e3d0..2cbf7713 100644
--- a/README.md
+++ b/README.md
@@ -99,6 +99,8 @@ graph TD
     ext --> api
 ```
 
+See `docs/ARCHITECTURE.md` for details.
+
 ## Prerequisites
 
 - **Python 3.8+** (3.11 or 3.12 recommended): Install Python from [python.org](https://www.python.org/downloads/) and be sure to check **"Add Python to PATH"** during setup. Using Python 3.13 is currently discouraged because some dependencies do not ship wheels for it yet, which can cause build failures on Windows unless you install the Visual C++ Build Tools.  
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 00000000..f8416b88
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,41 @@
+# SeedPass Architecture
+
+SeedPass follows a layered design that keeps the security-critical logic isolated in a reusable core package. Interfaces like the command line tool, REST API and graphical client act as thin adapters around this core.
+
+## Core Components
+
+- **`seedpass.core`** – houses all encryption, key derivation and vault management code.
+- **`VaultService`** and **`EntryService`** – thread-safe wrappers exposing the main API.
+- **`PasswordManager`** – orchestrates vault operations, migrations and Nostr sync.
+
+## Adapters
+
+- **CLI/TUI** – implemented in [`seedpass.cli`](src/seedpass/cli.py). The [Advanced CLI](docs/docs/content/01-getting-started/01-advanced_cli.md) guide details all commands.
+- **FastAPI server** – defined in [`seedpass.api`](src/seedpass/api.py). See the [API Reference](docs/docs/content/01-getting-started/02-api_reference.md) for endpoints.
+- **BeeWare GUI** – located in [`seedpass_gui`](src/seedpass_gui/app.py) and explained in the [GUI Adapter](docs/docs/content/01-getting-started/06-gui_adapter.md) page.
+
+## Planned Extensions
+
+SeedPass is built to support additional adapters. Planned or experimental options include:
+
+- A browser extension communicating with the API
+- Automation scripts using the CLI
+- Additional vault import/export helpers described in [JSON Entries](docs/docs/content/01-getting-started/03-json_entries.md)
+
+## Overview Diagram
+
+```mermaid
+graph TD
+    core["seedpass.core"]
+    cli["CLI / TUI"]
+    api["FastAPI server"]
+    gui["BeeWare GUI"]
+    ext["Browser extension"]
+
+    cli --> core
+    api --> core
+    gui --> core
+    ext --> api
+```
+
+All adapters depend on the same core, allowing features to evolve without duplicating logic across interfaces.

From d679d52b6614a46d1ab979c5a46c1641c27b1107 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 08:25:07 -0400
Subject: [PATCH 16/75] Refactor manager to accept provided credentials

---
 src/seedpass/cli.py                      |  8 +++-
 src/seedpass/core/api.py                 |  8 +++-
 src/seedpass/core/manager.py             | 59 +++++++++++++++---------
 src/tests/test_background_sync_always.py |  5 +-
 src/tests/test_cli_doc_examples.py       |  4 +-
 src/tests/test_manager_seed_setup.py     | 28 ++++++++++-
 src/tests/test_parent_seed_backup.py     | 10 +---
 src/tests/test_profiles.py               |  6 +--
 src/tests/test_typer_cli.py              | 10 ++--
 9 files changed, 88 insertions(+), 50 deletions(-)

diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py
index 309b3ace..81b1628a 100644
--- a/src/seedpass/cli.py
+++ b/src/seedpass/cli.py
@@ -462,8 +462,9 @@ def vault_reveal_parent_seed(
 ) -> None:
     """Display the parent seed and optionally write an encrypted backup file."""
     vault_service, _profile, _sync = _get_services(ctx)
+    password = typer.prompt("Master password", hide_input=True)
     vault_service.backup_parent_seed(
-        BackupParentSeedRequest(path=Path(file) if file else None)
+        BackupParentSeedRequest(path=Path(file) if file else None, password=password)
     )
 
 
@@ -630,7 +631,10 @@ def fingerprint_remove(ctx: typer.Context, fingerprint: str) -> None:
 def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None:
     """Switch to another seed profile."""
     _vault, profile_service, _sync = _get_services(ctx)
-    profile_service.switch_profile(ProfileSwitchRequest(fingerprint=fingerprint))
+    password = typer.prompt("Master password", hide_input=True)
+    profile_service.switch_profile(
+        ProfileSwitchRequest(fingerprint=fingerprint, password=password)
+    )
 
 
 @util_app.command("generate-password")
diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py
index 9f6671c0..eeb76af4 100644
--- a/src/seedpass/core/api.py
+++ b/src/seedpass/core/api.py
@@ -57,12 +57,14 @@ class BackupParentSeedRequest(BaseModel):
     """Optional path to write the encrypted seed backup."""
 
     path: Optional[Path] = None
+    password: Optional[str] = None
 
 
 class ProfileSwitchRequest(BaseModel):
     """Select a different seed profile."""
 
     fingerprint: str
+    password: Optional[str] = None
 
 
 class ProfileRemoveRequest(BaseModel):
@@ -123,7 +125,9 @@ class VaultService:
         """Backup and reveal the parent seed."""
 
         with self._lock:
-            self._manager.handle_backup_reveal_parent_seed(req.path)
+            self._manager.handle_backup_reveal_parent_seed(
+                req.path, password=req.password
+            )
 
     def stats(self) -> Dict:
         """Return statistics about the current profile."""
@@ -164,7 +168,7 @@ class ProfileService:
         """Switch to ``req.fingerprint``."""
 
         with self._lock:
-            self._manager.select_fingerprint(req.fingerprint)
+            self._manager.select_fingerprint(req.fingerprint, password=req.password)
 
 
 class SyncService:
diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py
index e42c8f75..06ffa079 100644
--- a/src/seedpass/core/manager.py
+++ b/src/seedpass/core/manager.py
@@ -546,7 +546,7 @@ class PasswordManager:
             print(colored(f"Error: Failed to load parent seed: {e}", "red"))
             sys.exit(1)
 
-    def handle_switch_fingerprint(self) -> bool:
+    def handle_switch_fingerprint(self, *, password: Optional[str] = None) -> bool:
         """
         Handles switching to a different seed profile.
 
@@ -587,9 +587,10 @@ class PasswordManager:
                 return False  # Return False to indicate failure
 
             # Prompt for master password for the selected seed profile
-            password = prompt_existing_password(
-                "Enter the master password for the selected seed profile: "
-            )
+            if password is None:
+                password = prompt_existing_password(
+                    "Enter the master password for the selected seed profile: "
+                )
 
             # Set up the encryption manager with the new password and seed profile directory
             if not self.setup_encryption_manager(
@@ -676,14 +677,14 @@ class PasswordManager:
         self.update_activity()
         self.start_background_sync()
 
-    def handle_existing_seed(self) -> None:
+    def handle_existing_seed(self, *, password: Optional[str] = None) -> None:
         """
         Handles the scenario where an existing parent seed file is found.
         Prompts the user for the master password to decrypt the seed.
         """
         try:
-            # Prompt for password using masked input
-            password = prompt_existing_password("Enter your login password: ")
+            if password is None:
+                password = prompt_existing_password("Enter your login password: ")
 
             # Derive encryption key from password
             iterations = (
@@ -778,7 +779,11 @@ class PasswordManager:
             sys.exit(1)
 
     def setup_existing_seed(
-        self, method: Literal["paste", "words"] = "paste"
+        self,
+        method: Literal["paste", "words"] = "paste",
+        *,
+        seed: Optional[str] = None,
+        password: Optional[str] = None,
     ) -> Optional[str]:
         """Prompt for an existing BIP-85 seed and set it up.
 
@@ -794,7 +799,9 @@ class PasswordManager:
             The fingerprint if setup is successful, ``None`` otherwise.
         """
         try:
-            if method == "words":
+            if seed is not None:
+                parent_seed = seed
+            elif method == "words":
                 parent_seed = prompt_seed_words()
             else:
                 parent_seed = masked_input("Enter your 12-word BIP-85 seed: ").strip()
@@ -804,17 +811,21 @@ class PasswordManager:
                 print(colored("Error: Invalid BIP-85 seed phrase.", "red"))
                 sys.exit(1)
 
-            return self._finalize_existing_seed(parent_seed)
+            return self._finalize_existing_seed(parent_seed, password=password)
         except KeyboardInterrupt:
             logging.info("Operation cancelled by user.")
             self.notify("Operation cancelled by user.", level="WARNING")
             sys.exit(0)
 
-    def setup_existing_seed_word_by_word(self) -> Optional[str]:
+    def setup_existing_seed_word_by_word(
+        self, *, seed: Optional[str] = None, password: Optional[str] = None
+    ) -> Optional[str]:
         """Prompt for an existing seed one word at a time and set it up."""
-        return self.setup_existing_seed(method="words")
+        return self.setup_existing_seed(method="words", seed=seed, password=password)
 
-    def _finalize_existing_seed(self, parent_seed: str) -> Optional[str]:
+    def _finalize_existing_seed(
+        self, parent_seed: str, *, password: Optional[str] = None
+    ) -> Optional[str]:
         """Common logic for initializing an existing seed."""
         if self.validate_bip85_seed(parent_seed):
             fingerprint = self.fingerprint_manager.add_fingerprint(parent_seed)
@@ -842,7 +853,8 @@ class PasswordManager:
             logging.info(f"Current seed profile set to {fingerprint}")
 
             try:
-                password = prompt_for_password()
+                if password is None:
+                    password = prompt_for_password()
                 index_key = derive_index_key(parent_seed)
                 iterations = (
                     self.config_manager.get_kdf_iterations()
@@ -976,7 +988,9 @@ class PasswordManager:
             print(colored(f"Error: Failed to generate BIP-85 seed: {e}", "red"))
             sys.exit(1)
 
-    def save_and_encrypt_seed(self, seed: str, fingerprint_dir: Path) -> None:
+    def save_and_encrypt_seed(
+        self, seed: str, fingerprint_dir: Path, *, password: Optional[str] = None
+    ) -> None:
         """
         Saves and encrypts the parent seed.
 
@@ -988,8 +1002,8 @@ class PasswordManager:
             # Set self.fingerprint_dir
             self.fingerprint_dir = fingerprint_dir
 
-            # Prompt for password
-            password = prompt_for_password()
+            if password is None:
+                password = prompt_for_password()
 
             index_key = derive_index_key(seed)
             iterations = (
@@ -3732,7 +3746,9 @@ class PasswordManager:
             print(colored(f"Error: Failed to export 2FA codes: {e}", "red"))
             return None
 
-    def handle_backup_reveal_parent_seed(self, file: Path | None = None) -> None:
+    def handle_backup_reveal_parent_seed(
+        self, file: Path | None = None, *, password: Optional[str] = None
+    ) -> None:
         """Reveal the parent seed and optionally save an encrypted backup.
 
         Parameters
@@ -3762,9 +3778,10 @@ class PasswordManager:
             )
 
             # Verify user's identity with secure password verification
-            password = prompt_existing_password(
-                "Enter your master password to continue: "
-            )
+            if password is None:
+                password = prompt_existing_password(
+                    "Enter your master password to continue: "
+                )
             if not self.verify_password(password):
                 print(colored("Incorrect password. Operation aborted.", "red"))
                 return
diff --git a/src/tests/test_background_sync_always.py b/src/tests/test_background_sync_always.py
index e94a8999..f266489f 100644
--- a/src/tests/test_background_sync_always.py
+++ b/src/tests/test_background_sync_always.py
@@ -22,9 +22,6 @@ def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path):
     pm.config_manager = SimpleNamespace(get_quick_unlock=lambda: False)
 
     monkeypatch.setattr("builtins.input", lambda *_a, **_k: "1")
-    monkeypatch.setattr(
-        "seedpass.core.manager.prompt_existing_password", lambda *_a, **_k: "pw"
-    )
     monkeypatch.setattr(
         PasswordManager, "setup_encryption_manager", lambda *a, **k: True
     )
@@ -39,7 +36,7 @@ def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path):
 
     monkeypatch.setattr(PasswordManager, "start_background_sync", fake_bg)
 
-    assert pm.handle_switch_fingerprint()
+    assert pm.handle_switch_fingerprint(password="pw")
     assert calls["count"] == 1
 
 
diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py
index f20a2f6c..03f162e4 100644
--- a/src/tests/test_cli_doc_examples.py
+++ b/src/tests/test_cli_doc_examples.py
@@ -43,7 +43,7 @@ class DummyPM:
         self.change_password = lambda *a, **kw: None
         self.lock_vault = lambda: None
         self.get_profile_stats = lambda: {"n": 1}
-        self.handle_backup_reveal_parent_seed = lambda path=None: None
+        self.handle_backup_reveal_parent_seed = lambda path=None, **_: None
         self.handle_verify_checksum = lambda: None
         self.handle_update_script_checksum = lambda: None
         self.add_new_fingerprint = lambda: None
@@ -76,7 +76,7 @@ class DummyPM:
         )
         self.secret_mode_enabled = True
         self.clipboard_clear_delay = 30
-        self.select_fingerprint = lambda fp: None
+        self.select_fingerprint = lambda fp, **_: None
 
 
 def load_doc_commands() -> list[str]:
diff --git a/src/tests/test_manager_seed_setup.py b/src/tests/test_manager_seed_setup.py
index 3c1a2456..47593737 100644
--- a/src/tests/test_manager_seed_setup.py
+++ b/src/tests/test_manager_seed_setup.py
@@ -36,7 +36,7 @@ def test_setup_existing_seed_words(monkeypatch):
     monkeypatch.setattr(builtins, "input", lambda *_: "y")
 
     pm = PasswordManager.__new__(PasswordManager)
-    monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed: seed)
+    monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed, **_: seed)
 
     result = pm.setup_existing_seed(method="words")
     assert result == phrase
@@ -60,8 +60,32 @@ def test_setup_existing_seed_paste(monkeypatch):
     )
 
     pm = PasswordManager.__new__(PasswordManager)
-    monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed: seed)
+    monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed, **_: seed)
 
     result = pm.setup_existing_seed(method="paste")
     assert result == phrase
     assert called["prompt"].startswith("Enter your 12-word BIP-85 seed")
+
+
+def test_setup_existing_seed_with_args(monkeypatch):
+    m = Mnemonic("english")
+    phrase = m.generate(strength=128)
+
+    called = {}
+
+    monkeypatch.setattr(
+        "seedpass.core.manager.masked_input",
+        lambda *_: (_ for _ in ()).throw(RuntimeError("prompt")),
+    )
+
+    def finalize(seed, *, password=None):
+        called["seed"] = seed
+        called["pw"] = password
+        return "fp"
+
+    pm = PasswordManager.__new__(PasswordManager)
+    monkeypatch.setattr(pm, "_finalize_existing_seed", finalize)
+    result = pm.setup_existing_seed(method="paste", seed=phrase, password="pw")
+    assert result == "fp"
+    assert called["seed"] == phrase
+    assert called["pw"] == "pw"
diff --git a/src/tests/test_parent_seed_backup.py b/src/tests/test_parent_seed_backup.py
index 1f709208..a25fd4a0 100644
--- a/src/tests/test_parent_seed_backup.py
+++ b/src/tests/test_parent_seed_backup.py
@@ -24,9 +24,6 @@ def _make_pm(tmp_path: Path) -> PasswordManager:
 def test_handle_backup_reveal_parent_seed_confirm(monkeypatch, tmp_path, capsys):
     pm = _make_pm(tmp_path)
 
-    monkeypatch.setattr(
-        "seedpass.core.manager.prompt_existing_password", lambda *_: "pw"
-    )
     confirms = iter([True, True])
     monkeypatch.setattr(
         "seedpass.core.manager.confirm_action", lambda *_a, **_k: next(confirms)
@@ -39,7 +36,7 @@ def test_handle_backup_reveal_parent_seed_confirm(monkeypatch, tmp_path, capsys)
     pm.encryption_manager = SimpleNamespace(encrypt_and_save_file=fake_save)
     monkeypatch.setattr(builtins, "input", lambda *_: "mybackup.enc")
 
-    pm.handle_backup_reveal_parent_seed()
+    pm.handle_backup_reveal_parent_seed(password="pw")
     out = capsys.readouterr().out
 
     assert "seed phrase" in out
@@ -50,16 +47,13 @@ def test_handle_backup_reveal_parent_seed_confirm(monkeypatch, tmp_path, capsys)
 def test_handle_backup_reveal_parent_seed_cancel(monkeypatch, tmp_path, capsys):
     pm = _make_pm(tmp_path)
 
-    monkeypatch.setattr(
-        "seedpass.core.manager.prompt_existing_password", lambda *_: "pw"
-    )
     monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: False)
     saved = []
     pm.encryption_manager = SimpleNamespace(
         encrypt_and_save_file=lambda data, path: saved.append((data, path))
     )
 
-    pm.handle_backup_reveal_parent_seed()
+    pm.handle_backup_reveal_parent_seed(password="pw")
     out = capsys.readouterr().out
 
     assert "seed phrase" not in out
diff --git a/src/tests/test_profiles.py b/src/tests/test_profiles.py
index fbbf097d..35e05db4 100644
--- a/src/tests/test_profiles.py
+++ b/src/tests/test_profiles.py
@@ -31,10 +31,6 @@ def test_add_and_switch_fingerprint(monkeypatch):
         pm.current_fingerprint = None
 
         monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "1")
-        monkeypatch.setattr(
-            "seedpass.core.manager.prompt_existing_password",
-            lambda *_a, **_k: "pass",
-        )
         monkeypatch.setattr(
             PasswordManager,
             "setup_encryption_manager",
@@ -50,7 +46,7 @@ def test_add_and_switch_fingerprint(monkeypatch):
             "seedpass.core.manager.NostrClient", lambda *a, **kw: object()
         )
 
-        assert pm.handle_switch_fingerprint()
+        assert pm.handle_switch_fingerprint(password="pass")
         assert pm.current_fingerprint == fingerprint
         assert fm.current_fingerprint == fingerprint
         assert pm.fingerprint_dir == expected_dir
diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py
index cce6e6ea..b0b2479f 100644
--- a/src/tests/test_typer_cli.py
+++ b/src/tests/test_typer_cli.py
@@ -156,7 +156,7 @@ def test_vault_lock(monkeypatch):
 def test_vault_reveal_parent_seed(monkeypatch, tmp_path):
     called = {}
 
-    def reveal(path=None):
+    def reveal(path=None, **_):
         called["path"] = path
 
     pm = SimpleNamespace(
@@ -165,7 +165,9 @@ def test_vault_reveal_parent_seed(monkeypatch, tmp_path):
     monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
     out_path = tmp_path / "seed.enc"
     result = runner.invoke(
-        app, ["vault", "reveal-parent-seed", "--file", str(out_path)]
+        app,
+        ["vault", "reveal-parent-seed", "--file", str(out_path)],
+        input="pw\n",
     )
     assert result.exit_code == 0
     assert called["path"] == out_path
@@ -231,14 +233,14 @@ def test_fingerprint_remove(monkeypatch):
 def test_fingerprint_switch(monkeypatch):
     called = {}
 
-    def switch(fp):
+    def switch(fp, **_):
         called["fp"] = fp
 
     pm = SimpleNamespace(
         select_fingerprint=switch, fingerprint_manager=SimpleNamespace()
     )
     monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
-    result = runner.invoke(app, ["fingerprint", "switch", "def"])
+    result = runner.invoke(app, ["fingerprint", "switch", "def"], input="pw\n")
     assert result.exit_code == 0
     assert called.get("fp") == "def"
 

From d93f47e3eee7d3d6dd58d10ae8d5ce6f82bea518 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 08:46:34 -0400
Subject: [PATCH 17/75] Add basic GUI windows and headless tests

---
 src/seedpass_gui/__init__.py   |  4 +-
 src/seedpass_gui/app.py        | 23 +++++++-----
 src/tests/test_gui_headless.py | 69 ++++++++++++++++++++++++++++++++++
 3 files changed, 85 insertions(+), 11 deletions(-)
 create mode 100644 src/tests/test_gui_headless.py

diff --git a/src/seedpass_gui/__init__.py b/src/seedpass_gui/__init__.py
index f473abca..4ef96bc2 100644
--- a/src/seedpass_gui/__init__.py
+++ b/src/seedpass_gui/__init__.py
@@ -1,11 +1,11 @@
 """Graphical user interface for SeedPass."""
 
-from .app import SeedPassApp
+from .app import SeedPassApp, build
 
 
 def main() -> None:
     """Launch the GUI application."""
-    SeedPassApp().main_loop()
+    build().main_loop()
 
 
 __all__ = ["SeedPassApp", "main"]
diff --git a/src/seedpass_gui/app.py b/src/seedpass_gui/app.py
index b3b84cbf..f1578f13 100644
--- a/src/seedpass_gui/app.py
+++ b/src/seedpass_gui/app.py
@@ -16,10 +16,12 @@ class LockScreenWindow(toga.Window):
     """Window prompting for the master password."""
 
     def __init__(
-        self, app: SeedPassApp, vault: VaultService, entries: EntryService
+        self, controller: SeedPassApp, vault: VaultService, entries: EntryService
     ) -> None:
         super().__init__("Unlock Vault")
-        self.app = app
+        # Store a reference to the SeedPass application instance separately from
+        # the ``toga`` ``Window.app`` attribute to avoid conflicts.
+        self.controller = controller
         self.vault = vault
         self.entries = entries
 
@@ -43,8 +45,8 @@ class LockScreenWindow(toga.Window):
         except Exception as exc:  # pragma: no cover - GUI error handling
             self.message.text = str(exc)
             return
-        main = MainWindow(self.app, self.vault, self.entries)
-        self.app.main_window = main
+        main = MainWindow(self.controller, self.vault, self.entries)
+        self.controller.main_window = main
         main.show()
         self.close()
 
@@ -53,10 +55,12 @@ class MainWindow(toga.Window):
     """Main application window showing vault entries."""
 
     def __init__(
-        self, app: SeedPassApp, vault: VaultService, entries: EntryService
+        self, controller: SeedPassApp, vault: VaultService, entries: EntryService
     ) -> None:
         super().__init__("SeedPass")
-        self.app = app
+        # ``Window.app`` is reserved for the Toga ``App`` instance. Store the
+        # SeedPass application reference separately.
+        self.controller = controller
         self.vault = vault
         self.entries = entries
 
@@ -115,7 +119,7 @@ class EntryDialog(toga.Window):
         self.username_input = toga.TextInput(style=Pack(flex=1))
         self.url_input = toga.TextInput(style=Pack(flex=1))
         self.length_input = toga.NumberInput(
-            min_value=8, max_value=128, style=Pack(width=80), value=16
+            min=8, max=128, style=Pack(width=80), value=16
         )
 
         save_button = toga.Button(
@@ -184,7 +188,8 @@ class SearchDialog(toga.Window):
 
 
 def build() -> SeedPassApp:
-    return SeedPassApp()
+    """Return a configured :class:`SeedPassApp` instance."""
+    return SeedPassApp(formal_name="SeedPass", app_id="org.seedpass.gui")
 
 
 class SeedPassApp(toga.App):
@@ -201,4 +206,4 @@ class SeedPassApp(toga.App):
 
 def main() -> None:  # pragma: no cover - GUI bootstrap
     """Run the BeeWare application."""
-    SeedPassApp().main_loop()
+    build().main_loop()
diff --git a/src/tests/test_gui_headless.py b/src/tests/test_gui_headless.py
new file mode 100644
index 00000000..67b55578
--- /dev/null
+++ b/src/tests/test_gui_headless.py
@@ -0,0 +1,69 @@
+import os
+from types import SimpleNamespace
+
+import toga
+
+from seedpass_gui.app import LockScreenWindow, MainWindow, EntryDialog
+
+
+class FakeVault:
+    def __init__(self):
+        self.called = False
+
+    def unlock(self, request):
+        self.called = True
+
+
+class FakeEntries:
+    def __init__(self):
+        self.added = []
+        self.modified = []
+
+    def list_entries(self):
+        return []
+
+    def search_entries(self, query):
+        return []
+
+    def add_entry(self, label, length, username=None, url=None):
+        self.added.append((label, length, username, url))
+        return 1
+
+    def modify_entry(self, entry_id, username=None, url=None, label=None):
+        self.modified.append((entry_id, username, url, label))
+
+
+def setup_module(module):
+    os.environ["TOGA_BACKEND"] = "toga_dummy"
+    import asyncio
+
+    asyncio.set_event_loop(asyncio.new_event_loop())
+
+
+def test_unlock_creates_main_window():
+    app = toga.App("Test", "org.example")
+    controller = SimpleNamespace(main_window=None)
+    vault = FakeVault()
+    entries = FakeEntries()
+
+    win = LockScreenWindow(controller, vault, entries)
+    win.password_input.value = "pw"
+    win.handle_unlock(None)
+
+    assert vault.called
+    assert isinstance(controller.main_window, MainWindow)
+
+
+def test_entrydialog_add_calls_service():
+    toga.App("Test2", "org.example2")
+    entries = FakeEntries()
+    main = SimpleNamespace(entries=entries, refresh_entries=lambda: None)
+
+    dlg = EntryDialog(main, None)
+    dlg.label_input.value = "L"
+    dlg.username_input.value = "u"
+    dlg.url_input.value = "x"
+    dlg.length_input.value = 12
+    dlg.save(None)
+
+    assert entries.added == [("L", 12, "u", "x")]

From 2874bf0f824b2493ca5e43b44dbe329ca387222d Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 08:59:41 -0400
Subject: [PATCH 18/75] Install headless GUI dependencies

---
 src/requirements.txt | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/src/requirements.txt b/src/requirements.txt
index c0e094c9..0c335d99 100644
--- a/src/requirements.txt
+++ b/src/requirements.txt
@@ -33,3 +33,5 @@ python-multipart
 orjson
 argon2-cffi
 toga-core>=0.5.2
+pillow
+toga-dummy>=0.5.2  # for headless GUI tests

From dcda452da8f02ac3eff3b239db66b162049440bb Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 09:48:13 -0400
Subject: [PATCH 19/75] Add GUI getting started docs

---
 README.md                                     | 31 +++++++++++++++++++
 .../01-getting-started/06-gui_adapter.md      | 30 ++++++++++++++++++
 2 files changed, 61 insertions(+)

diff --git a/README.md b/README.md
index 2cbf7713..a72ce57b 100644
--- a/README.md
+++ b/README.md
@@ -229,6 +229,37 @@ seedpass list --filter totp
 
 For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md). Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md).
 
+### Getting Started with the GUI
+
+SeedPass also ships with a simple BeeWare desktop interface. Launch it from
+your virtual environment using:
+
+```bash
+python -m seedpass_gui
+```
+
+If installed globally, you can run the `seedpass-gui` entry point instead:
+
+```bash
+seedpass-gui
+```
+
+The GUI works with the same vault and configuration files as the CLI.
+
+```mermaid
+graph TD
+    core["seedpass.core"]
+    cli["CLI"]
+    api["FastAPI server"]
+    gui["BeeWare GUI"]
+    ext["Browser Extension"]
+
+    cli --> core
+    gui --> core
+    api --> core
+    ext --> api
+```
+
 ### Vault JSON Layout
 
 The encrypted index file `seedpass_entries_db.json.enc` begins with `schema_version` `2` and stores an `entries` map keyed by entry numbers.
diff --git a/docs/docs/content/01-getting-started/06-gui_adapter.md b/docs/docs/content/01-getting-started/06-gui_adapter.md
index 3d9c9b59..c45aef51 100644
--- a/docs/docs/content/01-getting-started/06-gui_adapter.md
+++ b/docs/docs/content/01-getting-started/06-gui_adapter.md
@@ -2,6 +2,36 @@
 
 SeedPass ships with a proof-of-concept graphical interface built using [BeeWare](https://beeware.org). The GUI interacts with the same core services as the CLI by instantiating wrappers around `PasswordManager`.
 
+## Getting Started with the GUI
+
+After installing the project dependencies, launch the desktop interface with:
+
+```bash
+python -m seedpass_gui
+```
+
+If you installed the package globally, you can use the provided entry point:
+
+```bash
+seedpass-gui
+```
+
+The GUI shares the same encrypted vault and configuration as the command line tool.
+
+```mermaid
+graph TD
+    core["seedpass.core"]
+    cli["CLI"]
+    api["FastAPI server"]
+    gui["BeeWare GUI"]
+    ext["Browser Extension"]
+
+    cli --> core
+    gui --> core
+    api --> core
+    ext --> api
+```
+
 ## VaultService and EntryService
 
 `VaultService` provides thread-safe access to vault operations like exporting, importing, unlocking and locking the vault. `EntryService` exposes methods for listing, searching and modifying entries. Both classes live in `seedpass.core.api` and hold a `PasswordManager` instance protected by a `threading.Lock` to ensure safe concurrent access.

From 37c78f608a12712545528de1f567b104eef464f0 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 10:39:52 -0400
Subject: [PATCH 20/75] Add module entry point

---
 src/seedpass_gui/__main__.py | 4 ++++
 1 file changed, 4 insertions(+)
 create mode 100644 src/seedpass_gui/__main__.py

diff --git a/src/seedpass_gui/__main__.py b/src/seedpass_gui/__main__.py
new file mode 100644
index 00000000..37c2a2dd
--- /dev/null
+++ b/src/seedpass_gui/__main__.py
@@ -0,0 +1,4 @@
+from .app import main
+
+if __name__ == "__main__":
+    main()

From 7ef7246361ee9ebbbc3c586c2f11230ad97eeaea Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 10:57:00 -0400
Subject: [PATCH 21/75] Add GUI command

---
 src/seedpass/cli.py         |  8 ++++++++
 src/tests/test_typer_cli.py | 16 ++++++++++++++++
 2 files changed, 24 insertions(+)

diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py
index 81b1628a..1a6c80a4 100644
--- a/src/seedpass/cli.py
+++ b/src/seedpass/cli.py
@@ -682,5 +682,13 @@ def api_stop(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> N
         typer.echo(f"Failed to stop server: {exc}")
 
 
+@app.command()
+def gui() -> None:
+    """Launch the BeeWare GUI."""
+    from seedpass_gui.app import main
+
+    main()
+
+
 if __name__ == "__main__":
     app()
diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py
index b0b2479f..e8e67258 100644
--- a/src/tests/test_typer_cli.py
+++ b/src/tests/test_typer_cli.py
@@ -532,3 +532,19 @@ def test_tui_forward_fingerprint(monkeypatch):
     result = runner.invoke(app, ["--fingerprint", "abc"])
     assert result.exit_code == 0
     assert called.get("fp") == "abc"
+
+
+def test_gui_command(monkeypatch):
+    called = {}
+
+    def fake_main():
+        called["called"] = True
+
+    monkeypatch.setitem(
+        sys.modules,
+        "seedpass_gui.app",
+        SimpleNamespace(main=fake_main),
+    )
+    result = runner.invoke(app, ["gui"])
+    assert result.exit_code == 0
+    assert called.get("called") is True

From 66dfb9d20541649ce1dd90b04517d5cfe33592ff Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 11:11:13 -0400
Subject: [PATCH 22/75] Add Briefcase config and update docs

---
 .../01-getting-started/05-briefcase.md        | 15 ++++++--
 pyproject.toml                                | 35 +++++++++++++++++++
 2 files changed, 47 insertions(+), 3 deletions(-)

diff --git a/docs/docs/content/01-getting-started/05-briefcase.md b/docs/docs/content/01-getting-started/05-briefcase.md
index c461f448..e7b82aa7 100644
--- a/docs/docs/content/01-getting-started/05-briefcase.md
+++ b/docs/docs/content/01-getting-started/05-briefcase.md
@@ -15,6 +15,15 @@ briefcase build
 briefcase run
 ```
 
-`briefcase create` only needs to be executed once per platform. After the
-initial creation step you can repeatedly run `briefcase build` followed by
-`briefcase run` to test your packaged application on Windows, macOS or Linux.
+## Command Overview
+
+- **`briefcase create`** — generates the project scaffold for your
+  operating system. Run this once per platform.
+- **`briefcase build`** — compiles dependencies and produces the
+  distributable bundle.
+- **`briefcase run`** — launches the packaged application so you can test
+  it locally.
+
+After the initial creation step you can repeatedly run `briefcase build`
+followed by `briefcase run` to test your packaged application on Windows,
+macOS or Linux.
diff --git a/pyproject.toml b/pyproject.toml
index 866aa676..81580057 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,3 +10,38 @@ seedpass-gui = "seedpass_gui.app:main"
 python_version = "3.11"
 strict = true
 mypy_path = "src"
+
+[tool.briefcase.app.seedpass-gui]
+formal-name = "SeedPass"
+description = "Deterministic password manager with a BeeWare GUI"
+sources = ["src"]
+requires = [
+    "toga-core>=0.5.2",
+    "colorama>=0.4.6",
+    "termcolor>=1.1.0",
+    "cryptography>=40.0.2",
+    "bip-utils>=2.5.0",
+    "bech32==1.2.0",
+    "coincurve>=18.0.0",
+    "mnemonic",
+    "aiohttp>=3.12.14",
+    "bcrypt",
+    "portalocker>=2.8",
+    "nostr-sdk>=0.42.1",
+    "websocket-client==1.7.0",
+    "websockets>=15.0.0",
+    "tomli",
+    "pgpy==0.6.0",
+    "pyotp>=2.8.0",
+    "pyperclip",
+    "qrcode>=8.2",
+    "typer>=0.12.3",
+    "fastapi>=0.116.0",
+    "uvicorn>=0.35.0",
+    "httpx>=0.28.1",
+    "requests>=2.32",
+    "python-multipart",
+    "orjson",
+    "argon2-cffi",
+]
+icon = "logo/png/SeedPass-Logo-24.png"

From 726e88de199723df332af341a8dce43b993ea438 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 11:30:39 -0400
Subject: [PATCH 23/75] Remove unused run_gui script

---
 src/run_gui.py | 4 ----
 1 file changed, 4 deletions(-)
 delete mode 100644 src/run_gui.py

diff --git a/src/run_gui.py b/src/run_gui.py
deleted file mode 100644
index 9f757c71..00000000
--- a/src/run_gui.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from seedpass_gui.app import main
-
-if __name__ == "__main__":
-    main()

From edea2d3a6f9d7dc00c0d5c65d4fb8584a61f9365 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 11:45:22 -0400
Subject: [PATCH 24/75] docs: expand GUI start options

---
 README.md                                          | 14 ++++++++------
 .../content/01-getting-started/06-gui_adapter.md   | 11 +++++------
 2 files changed, 13 insertions(+), 12 deletions(-)

diff --git a/README.md b/README.md
index a72ce57b..8e62cbb4 100644
--- a/README.md
+++ b/README.md
@@ -232,15 +232,11 @@ For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md
 ### Getting Started with the GUI
 
 SeedPass also ships with a simple BeeWare desktop interface. Launch it from
-your virtual environment using:
+your virtual environment using any of the following commands:
 
 ```bash
+seedpass gui
 python -m seedpass_gui
-```
-
-If installed globally, you can run the `seedpass-gui` entry point instead:
-
-```bash
 seedpass-gui
 ```
 
@@ -601,6 +597,12 @@ scripts/vendor_dependencies.sh
 pyinstaller SeedPass.spec
 ```
 
+You can also produce packaged installers for the GUI with BeeWare's Briefcase:
+
+```bash
+briefcase build
+```
+
 The standalone executable will appear in the `dist/` directory. This process works on Windows, macOS and Linux but you must build on each platform for a native binary.
 
 ## Security Considerations
diff --git a/docs/docs/content/01-getting-started/06-gui_adapter.md b/docs/docs/content/01-getting-started/06-gui_adapter.md
index c45aef51..e9303075 100644
--- a/docs/docs/content/01-getting-started/06-gui_adapter.md
+++ b/docs/docs/content/01-getting-started/06-gui_adapter.md
@@ -4,20 +4,19 @@ SeedPass ships with a proof-of-concept graphical interface built using [BeeWare]
 
 ## Getting Started with the GUI
 
-After installing the project dependencies, launch the desktop interface with:
+After installing the project dependencies, launch the desktop interface with one
+of the following commands:
 
 ```bash
+seedpass gui
 python -m seedpass_gui
-```
-
-If you installed the package globally, you can use the provided entry point:
-
-```bash
 seedpass-gui
 ```
 
 The GUI shares the same encrypted vault and configuration as the command line tool.
 
+To generate a packaged binary, run `briefcase build` (after the initial `briefcase create`).
+
 ```mermaid
 graph TD
     core["seedpass.core"]

From a83001a799c0383e14fdea5f9711a2c066556785 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 12:38:09 -0400
Subject: [PATCH 25/75] Allow unlock without explicit password

---
 src/seedpass/core/manager.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py
index 06ffa079..e14d55b8 100644
--- a/src/seedpass/core/manager.py
+++ b/src/seedpass/core/manager.py
@@ -276,7 +276,7 @@ class PasswordManager:
         self.config_manager = None
         self.locked = True
 
-    def unlock_vault(self, password: str) -> float:
+    def unlock_vault(self, password: Optional[str] = None) -> float:
         """Unlock the vault using the provided ``password``.
 
         Parameters
@@ -292,6 +292,8 @@ class PasswordManager:
         start = time.perf_counter()
         if not self.fingerprint_dir:
             raise ValueError("Fingerprint directory not set")
+        if password is None:
+            password = prompt_existing_password(self.get_password_prompt())
         self.setup_encryption_manager(self.fingerprint_dir, password)
         self.initialize_bip85()
         self.initialize_managers()

From 4491dd35df1863e9f49ee2d6e5ea046d27dfad85 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 13:01:31 -0400
Subject: [PATCH 26/75] Add build-system section

---
 pyproject.toml | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/pyproject.toml b/pyproject.toml
index 81580057..880a5c71 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -2,6 +2,10 @@
 name = "seedpass"
 version = "0.1.0"
 
+[build-system]
+requires = ["setuptools>=61", "wheel"]
+build-backend = "setuptools.build_meta"
+
 [project.scripts]
 seedpass = "seedpass.cli:app"
 seedpass-gui = "seedpass_gui.app:main"

From d89fa7f707539b09ad21040c2fc0f99b71f4e5c4 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 13:31:42 -0400
Subject: [PATCH 27/75] Add test for handle_add_password

---
 src/tests/test_manager_add_password.py | 81 ++++++++++++++++++++++++++
 1 file changed, 81 insertions(+)
 create mode 100644 src/tests/test_manager_add_password.py

diff --git a/src/tests/test_manager_add_password.py b/src/tests/test_manager_add_password.py
new file mode 100644
index 00000000..641bc5d3
--- /dev/null
+++ b/src/tests/test_manager_add_password.py
@@ -0,0 +1,81 @@
+import sys
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from types import SimpleNamespace
+
+import pytest
+
+from helpers import create_vault, TEST_SEED, TEST_PASSWORD, dummy_nostr_client
+
+sys.path.append(str(Path(__file__).resolve().parents[1]))
+
+from seedpass.core.entry_management import EntryManager
+from seedpass.core.backup import BackupManager
+from seedpass.core.manager import PasswordManager, EncryptionMode
+from seedpass.core.config_manager import ConfigManager
+from constants import DEFAULT_PASSWORD_LENGTH
+
+
+class FakePasswordGenerator:
+    def generate_password(self, length: int, index: int) -> str:  # noqa: D401
+        return f"pw-{index}-{length}"
+
+
+def test_handle_add_password(monkeypatch, dummy_nostr_client, capsys):
+    client, _relay = dummy_nostr_client
+    with TemporaryDirectory() as tmpdir:
+        tmp_path = Path(tmpdir)
+        vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
+        cfg_mgr = ConfigManager(vault, tmp_path)
+        backup_mgr = BackupManager(tmp_path, cfg_mgr)
+        entry_mgr = EntryManager(vault, backup_mgr)
+
+        pm = PasswordManager.__new__(PasswordManager)
+        pm.encryption_mode = EncryptionMode.SEED_ONLY
+        pm.encryption_manager = enc_mgr
+        pm.vault = vault
+        pm.entry_manager = entry_mgr
+        pm.backup_manager = backup_mgr
+        pm.password_generator = FakePasswordGenerator()
+        pm.parent_seed = TEST_SEED
+        pm.nostr_client = client
+        pm.fingerprint_dir = tmp_path
+        pm.secret_mode_enabled = False
+        pm.is_dirty = False
+
+        inputs = iter(
+            [
+                "Example",  # label
+                "",  # username
+                "",  # url
+                "",  # notes
+                "",  # tags
+                "n",  # add custom field
+                "",  # length (default)
+            ]
+        )
+        monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
+        monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None)
+        monkeypatch.setattr(pm, "start_background_vault_sync", lambda *a, **k: None)
+
+        pm.handle_add_password()
+        out = capsys.readouterr().out
+
+        entries = entry_mgr.list_entries(verbose=False)
+        assert entries == [(0, "Example", "", "", False)]
+
+        entry = entry_mgr.retrieve_entry(0)
+        assert entry == {
+            "label": "Example",
+            "length": DEFAULT_PASSWORD_LENGTH,
+            "username": "",
+            "url": "",
+            "archived": False,
+            "type": "password",
+            "kind": "password",
+            "notes": "",
+            "custom_fields": [],
+            "tags": [],
+        }
+
+        assert f"pw-0-{DEFAULT_PASSWORD_LENGTH}" in out

From d71a4912bd9db1a8f59f79d8d570ea1cd4c44a23 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 13:51:18 -0400
Subject: [PATCH 28/75] docs: clarify secret mode clipboard behavior

---
 README.md                              |  6 +--
 src/seedpass/cli.py                    |  6 ++-
 src/seedpass/core/manager.py           | 11 +++++-
 src/tests/test_manager_add_password.py | 52 ++++++++++++++++++++++++++
 4 files changed, 70 insertions(+), 5 deletions(-)

diff --git a/README.md b/README.md
index 8e62cbb4..84951ef9 100644
--- a/README.md
+++ b/README.md
@@ -55,7 +55,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
 - **Optional External Backup Location:** Configure a second directory where backups are automatically copied.
 - **Auto-Lock on Inactivity:** Vault locks after a configurable timeout for additional security.
 - **Quick Unlock:** Optionally skip the password prompt after verifying once.
-- **Secret Mode:** Copy retrieved passwords directly to your clipboard and automatically clear it after a delay.
+- **Secret Mode:** When enabled, newly generated and retrieved passwords are copied to your clipboard and automatically cleared after a delay.
 - **Tagging Support:** Organize entries with optional tags and find them quickly via search.
 - **Manual Vault Export/Import:** Create encrypted backups or restore them using the CLI or API.
 - **Parent Seed Backup:** Securely save an encrypted copy of the master seed.
@@ -386,11 +386,11 @@ When choosing **Add Entry**, you can now select from:
 
 ### Using Secret Mode
 
-When **Secret Mode** is enabled, SeedPass copies retrieved passwords directly to your clipboard instead of displaying them on screen. The clipboard clears automatically after the delay you choose.
+When **Secret Mode** is enabled, SeedPass copies newly generated and retrieved passwords directly to your clipboard instead of displaying them on screen. The clipboard clears automatically after the delay you choose.
 
 1. From the main menu open **Settings** and select **Toggle Secret Mode**.
 2. Choose how many seconds to keep passwords on the clipboard.
-3. Retrieve an entry and SeedPass will confirm the password was copied.
+3. Generate or retrieve an entry and SeedPass will confirm the password was copied.
 
 ### Viewing Entry Details
 
diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py
index 1a6c80a4..dd3f1afc 100644
--- a/src/seedpass/cli.py
+++ b/src/seedpass/cli.py
@@ -527,7 +527,11 @@ def config_set(ctx: typer.Context, key: str, value: str) -> None:
 
 @config_app.command("toggle-secret-mode")
 def config_toggle_secret_mode(ctx: typer.Context) -> None:
-    """Interactively enable or disable secret mode."""
+    """Interactively enable or disable secret mode.
+
+    When enabled, newly generated and retrieved passwords are copied to the
+    clipboard instead of printed to the screen.
+    """
     service = _get_config_service(ctx)
     try:
         enabled = service.get_secret_mode_enabled()
diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py
index e14d55b8..ce302893 100644
--- a/src/seedpass/core/manager.py
+++ b/src/seedpass/core/manager.py
@@ -1356,7 +1356,16 @@ class PasswordManager:
                     "green",
                 )
             )
-            print(colored(f"Password for {website_name}: {password}\n", "yellow"))
+            if self.secret_mode_enabled:
+                copy_to_clipboard(password, self.clipboard_clear_delay)
+                print(
+                    colored(
+                        f"[+] Password copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.",
+                        "green",
+                    )
+                )
+            else:
+                print(colored(f"Password for {website_name}: {password}\n", "yellow"))
 
             # Automatically push the updated encrypted index to Nostr so the
             # latest changes are backed up remotely.
diff --git a/src/tests/test_manager_add_password.py b/src/tests/test_manager_add_password.py
index 641bc5d3..3579f483 100644
--- a/src/tests/test_manager_add_password.py
+++ b/src/tests/test_manager_add_password.py
@@ -79,3 +79,55 @@ def test_handle_add_password(monkeypatch, dummy_nostr_client, capsys):
         }
 
         assert f"pw-0-{DEFAULT_PASSWORD_LENGTH}" in out
+
+
+def test_handle_add_password_secret_mode(monkeypatch, dummy_nostr_client, capsys):
+    client, _relay = dummy_nostr_client
+    with TemporaryDirectory() as tmpdir:
+        tmp_path = Path(tmpdir)
+        vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
+        cfg_mgr = ConfigManager(vault, tmp_path)
+        backup_mgr = BackupManager(tmp_path, cfg_mgr)
+        entry_mgr = EntryManager(vault, backup_mgr)
+
+        pm = PasswordManager.__new__(PasswordManager)
+        pm.encryption_mode = EncryptionMode.SEED_ONLY
+        pm.encryption_manager = enc_mgr
+        pm.vault = vault
+        pm.entry_manager = entry_mgr
+        pm.backup_manager = backup_mgr
+        pm.password_generator = FakePasswordGenerator()
+        pm.parent_seed = TEST_SEED
+        pm.nostr_client = client
+        pm.fingerprint_dir = tmp_path
+        pm.secret_mode_enabled = True
+        pm.clipboard_clear_delay = 5
+        pm.is_dirty = False
+
+        inputs = iter(
+            [
+                "Example",  # label
+                "",  # username
+                "",  # url
+                "",  # notes
+                "",  # tags
+                "n",  # add custom field
+                "",  # length (default)
+            ]
+        )
+        monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
+        monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None)
+        monkeypatch.setattr(pm, "start_background_vault_sync", lambda *a, **k: None)
+
+        called = []
+        monkeypatch.setattr(
+            "seedpass.core.manager.copy_to_clipboard",
+            lambda text, delay: called.append((text, delay)),
+        )
+
+        pm.handle_add_password()
+        out = capsys.readouterr().out
+
+        assert f"pw-0-{DEFAULT_PASSWORD_LENGTH}" not in out
+        assert "copied to clipboard" in out
+        assert called == [(f"pw-0-{DEFAULT_PASSWORD_LENGTH}", 5)]

From ddfe17b77bfdc91ea3ec560503d98b8f1082d890 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 14:34:12 -0400
Subject: [PATCH 29/75] Add StateManager and relay CLI

---
 src/seedpass/cli.py                | 33 ++++++++++++
 src/seedpass/core/__init__.py      |  4 +-
 src/seedpass/core/api.py           | 18 +++++++
 src/seedpass/core/manager.py       | 21 +++++++-
 src/seedpass/core/state_manager.py | 85 ++++++++++++++++++++++++++++++
 src/tests/test_cli_relays.py       | 53 +++++++++++++++++++
 src/tests/test_state_manager.py    | 26 +++++++++
 7 files changed, 237 insertions(+), 3 deletions(-)
 create mode 100644 src/seedpass/core/state_manager.py
 create mode 100644 src/tests/test_cli_relays.py
 create mode 100644 src/tests/test_state_manager.py

diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py
index dd3f1afc..caf86b99 100644
--- a/src/seedpass/cli.py
+++ b/src/seedpass/cli.py
@@ -492,6 +492,39 @@ def nostr_get_pubkey(ctx: typer.Context) -> None:
     typer.echo(npub)
 
 
+@nostr_app.command("list-relays")
+def nostr_list_relays(ctx: typer.Context) -> None:
+    """Display configured Nostr relays."""
+    service = _get_nostr_service(ctx)
+    relays = service.list_relays()
+    for i, r in enumerate(relays, 1):
+        typer.echo(f"{i}: {r}")
+
+
+@nostr_app.command("add-relay")
+def nostr_add_relay(ctx: typer.Context, url: str) -> None:
+    """Add a relay URL."""
+    service = _get_nostr_service(ctx)
+    try:
+        service.add_relay(url)
+    except Exception as exc:  # pragma: no cover - pass through errors
+        typer.echo(f"Error: {exc}")
+        raise typer.Exit(code=1)
+    typer.echo("Added")
+
+
+@nostr_app.command("remove-relay")
+def nostr_remove_relay(ctx: typer.Context, idx: int) -> None:
+    """Remove a relay by index (1-based)."""
+    service = _get_nostr_service(ctx)
+    try:
+        service.remove_relay(idx)
+    except Exception as exc:  # pragma: no cover - pass through errors
+        typer.echo(f"Error: {exc}")
+        raise typer.Exit(code=1)
+    typer.echo("Removed")
+
+
 @config_app.command("get")
 def config_get(ctx: typer.Context, key: str) -> None:
     """Get a configuration value."""
diff --git a/src/seedpass/core/__init__.py b/src/seedpass/core/__init__.py
index 00d933cd..4610c5e6 100644
--- a/src/seedpass/core/__init__.py
+++ b/src/seedpass/core/__init__.py
@@ -4,7 +4,7 @@
 
 from importlib import import_module
 
-__all__ = ["PasswordManager", "ConfigManager", "Vault", "EntryType"]
+__all__ = ["PasswordManager", "ConfigManager", "Vault", "EntryType", "StateManager"]
 
 
 def __getattr__(name: str):
@@ -16,4 +16,6 @@ def __getattr__(name: str):
         return import_module(".vault", __name__).Vault
     if name == "EntryType":
         return import_module(".entry_types", __name__).EntryType
+    if name == "StateManager":
+        return import_module(".state_manager", __name__).StateManager
     raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py
index eeb76af4..a214a2ee 100644
--- a/src/seedpass/core/api.py
+++ b/src/seedpass/core/api.py
@@ -525,3 +525,21 @@ class NostrService:
     def get_pubkey(self) -> str:
         with self._lock:
             return self._manager.nostr_client.key_manager.get_npub()
+
+    def list_relays(self) -> list[str]:
+        with self._lock:
+            return self._manager.state_manager.list_relays()
+
+    def add_relay(self, url: str) -> None:
+        with self._lock:
+            self._manager.state_manager.add_relay(url)
+            self._manager.nostr_client.relays = (
+                self._manager.state_manager.list_relays()
+            )
+
+    def remove_relay(self, idx: int) -> None:
+        with self._lock:
+            self._manager.state_manager.remove_relay(idx)
+            self._manager.nostr_client.relays = (
+                self._manager.state_manager.list_relays()
+            )
diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py
index ce302893..74a0da11 100644
--- a/src/seedpass/core/manager.py
+++ b/src/seedpass/core/manager.py
@@ -95,6 +95,7 @@ from utils.fingerprint_manager import FingerprintManager
 # Import NostrClient
 from nostr.client import NostrClient, DEFAULT_RELAYS
 from .config_manager import ConfigManager
+from .state_manager import StateManager
 
 # Instantiate the logger
 logger = logging.getLogger(__name__)
@@ -140,6 +141,7 @@ class PasswordManager:
         self.bip85: Optional[BIP85] = None
         self.nostr_client: Optional[NostrClient] = None
         self.config_manager: Optional[ConfigManager] = None
+        self.state_manager: Optional[StateManager] = None
         self.notifications: queue.Queue[Notification] = queue.Queue()
         self._current_notification: Optional[Notification] = None
         self._notification_expiry: float = 0.0
@@ -157,6 +159,8 @@ class PasswordManager:
         self.last_unlock_duration: float | None = None
         self.verbose_timing: bool = False
         self._suppress_entry_actions_menu: bool = False
+        self.last_bip85_idx: int = 0
+        self.last_sync_ts: int = 0
 
         # Initialize the fingerprint manager first
         self.initialize_fingerprint_manager()
@@ -1073,6 +1077,7 @@ class PasswordManager:
                 vault=self.vault,
                 fingerprint_dir=self.fingerprint_dir,
             )
+            self.state_manager = StateManager(self.fingerprint_dir)
             self.backup_manager = BackupManager(
                 fingerprint_dir=self.fingerprint_dir,
                 config_manager=self.config_manager,
@@ -1091,7 +1096,15 @@ class PasswordManager:
 
             # Load relay configuration and initialize NostrClient
             config = self.config_manager.load_config()
-            relay_list = config.get("relays", list(DEFAULT_RELAYS))
+            if getattr(self, "state_manager", None) is not None:
+                state = self.state_manager.state
+                relay_list = state.get("relays", list(DEFAULT_RELAYS))
+                self.last_bip85_idx = state.get("last_bip85_idx", 0)
+                self.last_sync_ts = state.get("last_sync_ts", 0)
+            else:
+                relay_list = list(DEFAULT_RELAYS)
+                self.last_bip85_idx = 0
+                self.last_sync_ts = 0
             self.offline_mode = bool(config.get("offline_mode", False))
             self.inactivity_timeout = config.get(
                 "inactivity_timeout", INACTIVITY_TIMEOUT
@@ -3966,7 +3979,11 @@ class PasswordManager:
             self.password_generator.encryption_manager = new_enc_mgr
             self.store_hashed_password(new_password)
 
-            relay_list = config_data.get("relays", list(DEFAULT_RELAYS))
+            if getattr(self, "state_manager", None) is not None:
+                state = self.state_manager.state
+                relay_list = state.get("relays", list(DEFAULT_RELAYS))
+            else:
+                relay_list = list(DEFAULT_RELAYS)
             self.nostr_client = NostrClient(
                 encryption_manager=self.encryption_manager,
                 fingerprint=self.current_fingerprint,
diff --git a/src/seedpass/core/state_manager.py b/src/seedpass/core/state_manager.py
new file mode 100644
index 00000000..8d142f92
--- /dev/null
+++ b/src/seedpass/core/state_manager.py
@@ -0,0 +1,85 @@
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+from typing import List
+
+from utils.file_lock import exclusive_lock, shared_lock
+from nostr.client import DEFAULT_RELAYS
+
+
+class StateManager:
+    """Persist simple state values per profile."""
+
+    STATE_FILENAME = "seedpass_state.json"
+
+    def __init__(self, fingerprint_dir: Path) -> None:
+        self.fingerprint_dir = Path(fingerprint_dir)
+        self.state_path = self.fingerprint_dir / self.STATE_FILENAME
+
+    def _load(self) -> dict:
+        if not self.state_path.exists():
+            return {
+                "last_bip85_idx": 0,
+                "last_sync_ts": 0,
+                "relays": list(DEFAULT_RELAYS),
+            }
+        with shared_lock(self.state_path) as fh:
+            fh.seek(0)
+            data = fh.read()
+        if not data:
+            return {
+                "last_bip85_idx": 0,
+                "last_sync_ts": 0,
+                "relays": list(DEFAULT_RELAYS),
+            }
+        try:
+            obj = json.loads(data.decode())
+        except Exception:
+            obj = {}
+        obj.setdefault("last_bip85_idx", 0)
+        obj.setdefault("last_sync_ts", 0)
+        obj.setdefault("relays", list(DEFAULT_RELAYS))
+        return obj
+
+    def _save(self, data: dict) -> None:
+        with exclusive_lock(self.state_path) as fh:
+            fh.seek(0)
+            fh.truncate()
+            fh.write(json.dumps(data, separators=(",", ":")).encode())
+            fh.flush()
+            os.fsync(fh.fileno())
+
+    @property
+    def state(self) -> dict:
+        return self._load()
+
+    def update_state(self, **kwargs) -> None:
+        data = self._load()
+        data.update(kwargs)
+        self._save(data)
+
+    # Relay helpers
+    def list_relays(self) -> List[str]:
+        return self._load().get("relays", [])
+
+    def add_relay(self, url: str) -> None:
+        data = self._load()
+        relays = data.get("relays", [])
+        if url in relays:
+            raise ValueError("Relay already present")
+        relays.append(url)
+        data["relays"] = relays
+        self._save(data)
+
+    def remove_relay(self, idx: int) -> None:
+        data = self._load()
+        relays = data.get("relays", [])
+        if not 1 <= idx <= len(relays):
+            raise ValueError("Invalid index")
+        if len(relays) == 1:
+            raise ValueError("At least one relay required")
+        relays.pop(idx - 1)
+        data["relays"] = relays
+        self._save(data)
diff --git a/src/tests/test_cli_relays.py b/src/tests/test_cli_relays.py
new file mode 100644
index 00000000..fcfe5fc6
--- /dev/null
+++ b/src/tests/test_cli_relays.py
@@ -0,0 +1,53 @@
+from types import SimpleNamespace
+from typer.testing import CliRunner
+
+from seedpass.cli import app
+from seedpass import cli
+
+
+class DummyService:
+    def __init__(self, relays):
+        self.relays = relays
+
+    def get_pubkey(self):
+        return "npub"
+
+    def list_relays(self):
+        return self.relays
+
+    def add_relay(self, url):
+        if url in self.relays:
+            raise ValueError("exists")
+        self.relays.append(url)
+
+    def remove_relay(self, idx):
+        if not 1 <= idx <= len(self.relays):
+            raise ValueError("bad")
+        if len(self.relays) == 1:
+            raise ValueError("min")
+        self.relays.pop(idx - 1)
+
+
+runner = CliRunner()
+
+
+def test_cli_relay_crud(monkeypatch):
+    relays = ["wss://a"]
+
+    def pm_factory(*a, **k):
+        return SimpleNamespace()
+
+    monkeypatch.setattr(cli, "PasswordManager", pm_factory)
+    monkeypatch.setattr(cli, "NostrService", lambda pm: DummyService(relays))
+
+    result = runner.invoke(app, ["nostr", "list-relays"])
+    assert "1: wss://a" in result.stdout
+
+    result = runner.invoke(app, ["nostr", "add-relay", "wss://b"])
+    assert result.exit_code == 0
+    assert "Added" in result.stdout
+    assert relays == ["wss://a", "wss://b"]
+
+    result = runner.invoke(app, ["nostr", "remove-relay", "1"])
+    assert result.exit_code == 0
+    assert relays == ["wss://b"]
diff --git a/src/tests/test_state_manager.py b/src/tests/test_state_manager.py
new file mode 100644
index 00000000..0aef6d65
--- /dev/null
+++ b/src/tests/test_state_manager.py
@@ -0,0 +1,26 @@
+from tempfile import TemporaryDirectory
+from pathlib import Path
+
+from seedpass.core.state_manager import StateManager
+from nostr.client import DEFAULT_RELAYS
+
+
+def test_state_manager_round_trip():
+    with TemporaryDirectory() as tmpdir:
+        sm = StateManager(Path(tmpdir))
+        state = sm.state
+        assert state["relays"] == list(DEFAULT_RELAYS)
+        assert state["last_bip85_idx"] == 0
+        assert state["last_sync_ts"] == 0
+
+        sm.add_relay("wss://example.com")
+        sm.update_state(last_bip85_idx=5, last_sync_ts=123)
+
+        sm2 = StateManager(Path(tmpdir))
+        state2 = sm2.state
+        assert "wss://example.com" in state2["relays"]
+        assert state2["last_bip85_idx"] == 5
+        assert state2["last_sync_ts"] == 123
+
+        sm2.remove_relay(1)  # remove first default relay
+        assert len(sm2.list_relays()) == len(DEFAULT_RELAYS)

From b0ba723bdd5a042587627332428f080d6c55b35c Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 14:54:10 -0400
Subject: [PATCH 30/75] Extend entry search filtering

---
 src/seedpass/cli.py                   | 16 ++++-
 src/seedpass/core/api.py              | 16 ++++-
 src/seedpass/core/entry_management.py | 88 ++++++++-------------------
 src/tests/test_cli_core_services.py   | 10 +--
 src/tests/test_cli_doc_examples.py    |  2 +-
 src/tests/test_core_services.py       |  8 +--
 src/tests/test_gui_headless.py        |  2 +-
 src/tests/test_key_value_entry.py     |  2 +-
 src/tests/test_search_entries.py      | 28 +++++++--
 src/tests/test_typer_cli.py           |  4 +-
 10 files changed, 90 insertions(+), 86 deletions(-)

diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py
index caf86b99..f423deae 100644
--- a/src/seedpass/cli.py
+++ b/src/seedpass/cli.py
@@ -1,5 +1,5 @@
 from pathlib import Path
-from typing import Optional
+from typing import Optional, List
 import json
 
 import typer
@@ -135,10 +135,20 @@ def entry_list(
 
 
 @entry_app.command("search")
-def entry_search(ctx: typer.Context, query: str) -> None:
+def entry_search(
+    ctx: typer.Context,
+    query: str,
+    kind: List[str] = typer.Option(
+        None,
+        "--kind",
+        "-k",
+        help="Filter by entry kinds (can be repeated)",
+    ),
+) -> None:
     """Search entries."""
     service = _get_entry_service(ctx)
-    results = service.search_entries(query)
+    kinds = list(kind) if kind else None
+    results = service.search_entries(query, kinds=kinds)
     if not results:
         typer.echo("No matching entries found")
         return
diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py
index a214a2ee..6acb9588 100644
--- a/src/seedpass/core/api.py
+++ b/src/seedpass/core/api.py
@@ -220,9 +220,21 @@ class EntryService:
                 include_archived=include_archived,
             )
 
-    def search_entries(self, query: str):
+    def search_entries(
+        self, query: str, kinds: list[str] | None = None
+    ) -> list[tuple[int, str, str | None, str | None, bool]]:
+        """Search entries optionally filtering by ``kinds``.
+
+        Parameters
+        ----------
+        query:
+            Search string to match against entry metadata.
+        kinds:
+            Optional list of entry kinds to restrict the search.
+        """
+
         with self._lock:
-            return self._manager.entry_manager.search_entries(query)
+            return self._manager.entry_manager.search_entries(query, kinds=kinds)
 
     def retrieve_entry(self, entry_id: int):
         with self._lock:
diff --git a/src/seedpass/core/entry_management.py b/src/seedpass/core/entry_management.py
index 7a9673e8..ad0bc2e6 100644
--- a/src/seedpass/core/entry_management.py
+++ b/src/seedpass/core/entry_management.py
@@ -1045,9 +1045,10 @@ class EntryManager:
             return []
 
     def search_entries(
-        self, query: str
+        self, query: str, kinds: List[str] | None = None
     ) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]:
-        """Return entries matching the query across common fields."""
+        """Return entries matching ``query`` across whitelisted metadata fields."""
+
         data = self._load_index()
         entries_data = data.get("entries", {})
 
@@ -1059,74 +1060,33 @@ class EntryManager:
 
         for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])):
             etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
+
+            if kinds is not None and etype not in kinds:
+                continue
+
             label = entry.get("label", entry.get("website", ""))
-            notes = entry.get("notes", "")
+            username = (
+                entry.get("username", "") if etype == EntryType.PASSWORD.value else None
+            )
+            url = entry.get("url", "") if etype == EntryType.PASSWORD.value else None
             tags = entry.get("tags", [])
+            archived = entry.get("archived", entry.get("blacklisted", False))
+
             label_match = query_lower in label.lower()
-            notes_match = query_lower in notes.lower()
+            username_match = bool(username) and query_lower in username.lower()
+            url_match = bool(url) and query_lower in url.lower()
             tags_match = any(query_lower in str(t).lower() for t in tags)
 
-            if etype == EntryType.PASSWORD.value:
-                username = entry.get("username", "")
-                url = entry.get("url", "")
-                custom_fields = entry.get("custom_fields", [])
-                custom_match = any(
-                    query_lower in str(cf.get("label", "")).lower()
-                    or query_lower in str(cf.get("value", "")).lower()
-                    for cf in custom_fields
+            if label_match or username_match or url_match or tags_match:
+                results.append(
+                    (
+                        int(idx),
+                        label,
+                        username if username is not None else None,
+                        url if url is not None else None,
+                        archived,
+                    )
                 )
-                if (
-                    label_match
-                    or query_lower in username.lower()
-                    or query_lower in url.lower()
-                    or notes_match
-                    or custom_match
-                    or tags_match
-                ):
-                    results.append(
-                        (
-                            int(idx),
-                            label,
-                            username,
-                            url,
-                            entry.get("archived", entry.get("blacklisted", False)),
-                        )
-                    )
-            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(
-                    query_lower in str(cf.get("label", "")).lower()
-                    or query_lower in str(cf.get("value", "")).lower()
-                    for cf in custom_fields
-                )
-                if (
-                    label_match
-                    or query_lower in value_field.lower()
-                    or notes_match
-                    or custom_match
-                    or tags_match
-                ):
-                    results.append(
-                        (
-                            int(idx),
-                            label,
-                            None,
-                            None,
-                            entry.get("archived", entry.get("blacklisted", False)),
-                        )
-                    )
-            else:
-                if label_match or notes_match or tags_match:
-                    results.append(
-                        (
-                            int(idx),
-                            label,
-                            None,
-                            None,
-                            entry.get("archived", entry.get("blacklisted", False)),
-                        )
-                    )
 
         return results
 
diff --git a/src/tests/test_cli_core_services.py b/src/tests/test_cli_core_services.py
index 45a6d9d6..fd68b9a6 100644
--- a/src/tests/test_cli_core_services.py
+++ b/src/tests/test_cli_core_services.py
@@ -32,8 +32,8 @@ def test_cli_entry_add_search_sync(monkeypatch):
         calls["add"] = (label, length, username, url)
         return 1
 
-    def search_entries(q):
-        calls["search"] = q
+    def search_entries(q, kinds=None):
+        calls["search"] = (q, kinds)
         return [(1, "Label", None, None, False)]
 
     def sync_vault():
@@ -57,10 +57,12 @@ def test_cli_entry_add_search_sync(monkeypatch):
     assert calls.get("sync") is True
 
     # entry search
-    result = runner.invoke(app, ["entry", "search", "lab"])
+    result = runner.invoke(
+        app, ["entry", "search", "lab", "--kind", "password", "--kind", "totp"]
+    )
     assert result.exit_code == 0
     assert "Label" in result.stdout
-    assert calls["search"] == "lab"
+    assert calls["search"] == ("lab", ["password", "totp"])
 
     # nostr sync
     result = runner.invoke(app, ["nostr", "sync"])
diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py
index 03f162e4..7f4ce825 100644
--- a/src/tests/test_cli_doc_examples.py
+++ b/src/tests/test_cli_doc_examples.py
@@ -17,7 +17,7 @@ class DummyPM:
             list_entries=lambda sort_by="index", filter_kind=None, include_archived=False: [
                 (1, "Label", "user", "url", False)
             ],
-            search_entries=lambda q: [(1, "GitHub", "user", "", False)],
+            search_entries=lambda q, kinds=None: [(1, "GitHub", "user", "", False)],
             retrieve_entry=lambda idx: {"type": EntryType.PASSWORD.value, "length": 8},
             get_totp_code=lambda idx, seed: "123456",
             add_entry=lambda label, length, username, url: 1,
diff --git a/src/tests/test_core_services.py b/src/tests/test_core_services.py
index d71a859e..dc419c41 100644
--- a/src/tests/test_core_services.py
+++ b/src/tests/test_core_services.py
@@ -25,8 +25,8 @@ def test_entry_service_add_entry_and_search():
         called["add"] = (label, length, username, url)
         return 5
 
-    def search_entries(q):
-        called["search"] = q
+    def search_entries(q, kinds=None):
+        called["search"] = (q, kinds)
         return [(5, "Example", username, url, False)]
 
     def sync_vault():
@@ -46,9 +46,9 @@ def test_entry_service_add_entry_and_search():
     assert called["add"] == ("Example", 12, username, url)
     assert called.get("sync") is True
 
-    results = service.search_entries("ex")
+    results = service.search_entries("ex", kinds=["password"])
     assert results == [(5, "Example", username, url, False)]
-    assert called["search"] == "ex"
+    assert called["search"] == ("ex", ["password"])
 
 
 def test_sync_service_sync():
diff --git a/src/tests/test_gui_headless.py b/src/tests/test_gui_headless.py
index 67b55578..8801064f 100644
--- a/src/tests/test_gui_headless.py
+++ b/src/tests/test_gui_headless.py
@@ -22,7 +22,7 @@ class FakeEntries:
     def list_entries(self):
         return []
 
-    def search_entries(self, query):
+    def search_entries(self, query, kinds=None):
         return []
 
     def add_entry(self, label, length, username=None, url=None):
diff --git a/src/tests/test_key_value_entry.py b/src/tests/test_key_value_entry.py
index 86a46295..ededd035 100644
--- a/src/tests/test_key_value_entry.py
+++ b/src/tests/test_key_value_entry.py
@@ -41,4 +41,4 @@ def test_add_and_modify_key_value():
         assert updated["value"] == "def456"
 
         results = em.search_entries("def456")
-        assert results == [(idx, "API", None, None, False)]
+        assert results == []
diff --git a/src/tests/test_search_entries.py b/src/tests/test_search_entries.py
index 5e3f9212..9db94a2b 100644
--- a/src/tests/test_search_entries.py
+++ b/src/tests/test_search_entries.py
@@ -9,6 +9,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
 from seedpass.core.entry_management import EntryManager
 from seedpass.core.backup import BackupManager
 from seedpass.core.config_manager import ConfigManager
+from seedpass.core.entry_types import EntryType
 
 
 def setup_entry_manager(tmp_path: Path) -> EntryManager:
@@ -64,11 +65,12 @@ def test_search_by_notes_and_totp():
         idx_totp = entry_mgr.search_entries("GH")[0][0]
         entry_mgr.modify_entry(idx_totp, notes="otp note")
 
+        # notes are no longer searchable
         res_notes = entry_mgr.search_entries("secret")
-        assert res_notes == [(idx_pw, "Site", "", "", False)]
+        assert res_notes == []
 
         res_totp = entry_mgr.search_entries("otp")
-        assert res_totp == [(idx_totp, "GH", None, None, False)]
+        assert res_totp == []
 
 
 def test_search_by_custom_field():
@@ -83,7 +85,7 @@ def test_search_by_custom_field():
         idx = entry_mgr.add_entry("Example", 8, custom_fields=custom)
 
         result = entry_mgr.search_entries("secret123")
-        assert result == [(idx, "Example", "", "", False)]
+        assert result == []
 
 
 def test_search_key_value_value():
@@ -94,7 +96,7 @@ def test_search_key_value_value():
         idx = entry_mgr.add_key_value("API", "token123")
 
         result = entry_mgr.search_entries("token123")
-        assert result == [(idx, "API", None, None, False)]
+        assert result == []
 
 
 def test_search_no_results():
@@ -128,3 +130,21 @@ def test_search_by_tag_totp():
 
         result = entry_mgr.search_entries("mfa")
         assert result == [(idx, "OTPAccount", None, None, False)]
+
+
+def test_search_with_kind_filter():
+    with TemporaryDirectory() as tmpdir:
+        tmp_path = Path(tmpdir)
+        entry_mgr = setup_entry_manager(tmp_path)
+
+        idx_pw = entry_mgr.add_entry("Site", 8)
+        entry_mgr.add_totp("OTP", TEST_SEED)
+        idx_totp = entry_mgr.search_entries("OTP")[0][0]
+
+        all_results = entry_mgr.search_entries(
+            "", kinds=[EntryType.PASSWORD.value, EntryType.TOTP.value]
+        )
+        assert {r[0] for r in all_results} == {idx_pw, idx_totp}
+
+        only_pw = entry_mgr.search_entries("", kinds=[EntryType.PASSWORD.value])
+        assert only_pw == [(idx_pw, "Site", "", "", False)]
diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py
index e8e67258..eb71f620 100644
--- a/src/tests/test_typer_cli.py
+++ b/src/tests/test_typer_cli.py
@@ -34,7 +34,7 @@ def test_entry_list(monkeypatch):
 def test_entry_search(monkeypatch):
     pm = SimpleNamespace(
         entry_manager=SimpleNamespace(
-            search_entries=lambda q: [(1, "L", None, None, False)]
+            search_entries=lambda q, kinds=None: [(1, "L", None, None, False)]
         ),
         select_fingerprint=lambda fp: None,
     )
@@ -45,7 +45,7 @@ def test_entry_search(monkeypatch):
 
 
 def test_entry_get_password(monkeypatch):
-    def search(q):
+    def search(q, kinds=None):
         return [(2, "Example", "", "", False)]
 
     entry = {"type": EntryType.PASSWORD.value, "length": 8}

From b42ad0561ca3cf665a75f886dad50855b3e57255 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 15:22:26 -0400
Subject: [PATCH 31/75] Add sorting options to entry listing

---
 src/seedpass/cli.py                        |  2 +-
 src/seedpass/core/entry_management.py      | 24 +++++++++++++++-------
 src/tests/test_list_entries_sort_filter.py | 22 ++++++++++++--------
 3 files changed, 32 insertions(+), 16 deletions(-)

diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py
index f423deae..620678c4 100644
--- a/src/seedpass/cli.py
+++ b/src/seedpass/cli.py
@@ -113,7 +113,7 @@ def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) ->
 def entry_list(
     ctx: typer.Context,
     sort: str = typer.Option(
-        "index", "--sort", help="Sort by 'index', 'label', or 'username'"
+        "index", "--sort", help="Sort by 'index', 'label', or 'updated'"
     ),
     kind: Optional[str] = typer.Option(None, "--kind", help="Filter by entry type"),
     archived: bool = typer.Option(False, "--archived", help="Include archived"),
diff --git a/src/seedpass/core/entry_management.py b/src/seedpass/core/entry_management.py
index ad0bc2e6..406cfa86 100644
--- a/src/seedpass/core/entry_management.py
+++ b/src/seedpass/core/entry_management.py
@@ -922,10 +922,17 @@ class EntryManager:
         include_archived: bool = False,
         verbose: bool = True,
     ) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]:
-        """List entries in the index with optional sorting and filtering.
+        """List entries sorted and filtered according to the provided options.
 
-        By default archived entries are omitted unless ``include_archived`` is
-        ``True``.
+        Parameters
+        ----------
+        sort_by:
+            Field to sort by. Supported values are ``"index"``, ``"label"`` and
+            ``"updated"``.
+        filter_kind:
+            Optional entry kind to restrict the results.
+
+        Archived entries are omitted unless ``include_archived`` is ``True``.
         """
         try:
             data = self._load_index()
@@ -941,11 +948,14 @@ class EntryManager:
                 idx_str, entry = item
                 if sort_by == "index":
                     return int(idx_str)
-                if sort_by in {"website", "label"}:
+                if sort_by == "label":
+                    # labels are stored in the index so no additional
+                    # decryption is required when sorting
                     return entry.get("label", entry.get("website", "")).lower()
-                if sort_by == "username":
-                    return entry.get("username", "").lower()
-                raise ValueError("sort_by must be 'index', 'label', or 'username'")
+                if sort_by == "updated":
+                    # sort newest first
+                    return -int(entry.get("updated", 0))
+                raise ValueError("sort_by must be 'index', 'label', or 'updated'")
 
             sorted_items = sorted(entries_data.items(), key=sort_key)
 
diff --git a/src/tests/test_list_entries_sort_filter.py b/src/tests/test_list_entries_sort_filter.py
index 68d693f5..4361008c 100644
--- a/src/tests/test_list_entries_sort_filter.py
+++ b/src/tests/test_list_entries_sort_filter.py
@@ -19,29 +19,35 @@ def setup_entry_manager(tmp_path: Path) -> EntryManager:
     return EntryManager(vault, backup_mgr)
 
 
-def test_sort_by_website():
+def test_sort_by_label():
     with TemporaryDirectory() as tmpdir:
         tmp_path = Path(tmpdir)
         em = setup_entry_manager(tmp_path)
         idx0 = em.add_entry("b.com", 8, "user1")
         idx1 = em.add_entry("A.com", 8, "user2")
-        result = em.list_entries(sort_by="website")
+        result = em.list_entries(sort_by="label")
         assert result == [
             (idx1, "A.com", "user2", "", False),
             (idx0, "b.com", "user1", "", False),
         ]
 
 
-def test_sort_by_username():
+def test_sort_by_updated():
     with TemporaryDirectory() as tmpdir:
         tmp_path = Path(tmpdir)
         em = setup_entry_manager(tmp_path)
-        idx0 = em.add_entry("alpha.com", 8, "Charlie")
-        idx1 = em.add_entry("beta.com", 8, "alice")
-        result = em.list_entries(sort_by="username")
+        idx0 = em.add_entry("alpha.com", 8, "u0")
+        idx1 = em.add_entry("beta.com", 8, "u1")
+
+        data = em._load_index(force_reload=True)
+        data["entries"][str(idx0)]["updated"] = 1
+        data["entries"][str(idx1)]["updated"] = 2
+        em._save_index(data)
+
+        result = em.list_entries(sort_by="updated")
         assert result == [
-            (idx1, "beta.com", "alice", "", False),
-            (idx0, "alpha.com", "Charlie", "", False),
+            (idx1, "beta.com", "u1", "", False),
+            (idx0, "alpha.com", "u0", "", False),
         ]
 
 

From 11b370708747e79eead81327eb3cebe634776a4b Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 15:39:19 -0400
Subject: [PATCH 32/75] Add AuthGuard for inactivity locking and CLI lock
 command

---
 src/seedpass/cli.py               |  8 ++++++++
 src/seedpass/core/manager.py      | 26 +++++++++++++++++++++++++-
 src/tests/test_inactivity_lock.py | 29 +++++++++++++++++++++++++++++
 src/tests/test_typer_cli.py       | 17 +++++++++++++++++
 4 files changed, 79 insertions(+), 1 deletion(-)

diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py
index 620678c4..4b9a917a 100644
--- a/src/seedpass/cli.py
+++ b/src/seedpass/cli.py
@@ -455,6 +455,14 @@ def vault_lock(ctx: typer.Context) -> None:
     typer.echo("locked")
 
 
+@app.command("lock")
+def root_lock(ctx: typer.Context) -> None:
+    """Lock the vault for the active profile."""
+    vault_service, _profile, _sync = _get_services(ctx)
+    vault_service.lock()
+    typer.echo("locked")
+
+
 @vault_app.command("stats")
 def vault_stats(ctx: typer.Context) -> None:
     """Display statistics about the current seed profile."""
diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py
index 74a0da11..a707391b 100644
--- a/src/seedpass/core/manager.py
+++ b/src/seedpass/core/manager.py
@@ -109,6 +109,24 @@ class Notification:
     level: str = "INFO"
 
 
+class AuthGuard:
+    """Helper to enforce inactivity timeouts."""
+
+    def __init__(
+        self, manager: "PasswordManager", time_fn: callable = time.time
+    ) -> None:
+        self.manager = manager
+        self._time_fn = time_fn
+
+    def check_timeout(self) -> None:
+        """Lock the vault if the inactivity timeout has been exceeded."""
+        timeout = getattr(self.manager, "inactivity_timeout", 0)
+        if self.manager.locked or timeout <= 0:
+            return
+        if self._time_fn() - self.manager.last_activity > timeout:
+            self.manager.lock_vault()
+
+
 class PasswordManager:
     """
     PasswordManager Class
@@ -161,6 +179,7 @@ class PasswordManager:
         self._suppress_entry_actions_menu: bool = False
         self.last_bip85_idx: int = 0
         self.last_sync_ts: int = 0
+        self.auth_guard = AuthGuard(self)
 
         # Initialize the fingerprint manager first
         self.initialize_fingerprint_manager()
@@ -240,7 +259,12 @@ class PasswordManager:
         return (None, parent_fp, self.current_fingerprint)
 
     def update_activity(self) -> None:
-        """Record the current time as the last user activity."""
+        """Record activity and enforce inactivity timeout."""
+        guard = getattr(self, "auth_guard", None)
+        if guard is None:
+            guard = AuthGuard(self)
+            self.auth_guard = guard
+        guard.check_timeout()
         self.last_activity = time.time()
 
     def notify(self, message: str, level: str = "INFO") -> None:
diff --git a/src/tests/test_inactivity_lock.py b/src/tests/test_inactivity_lock.py
index 32d81da4..2befaedd 100644
--- a/src/tests/test_inactivity_lock.py
+++ b/src/tests/test_inactivity_lock.py
@@ -91,3 +91,32 @@ def test_input_timeout_triggers_lock(monkeypatch):
 
     assert locked["locked"] == 1
     assert locked["unlocked"] == 1
+
+
+def test_update_activity_checks_timeout(monkeypatch):
+    """AuthGuard in update_activity locks the vault after inactivity."""
+    import seedpass.core.manager as manager
+
+    now = {"val": 0.0}
+    monkeypatch.setattr(manager.time, "time", lambda: now["val"])
+
+    pm = manager.PasswordManager.__new__(manager.PasswordManager)
+    pm.inactivity_timeout = 0.5
+    pm.last_activity = 0.0
+    pm.locked = False
+    called = {}
+
+    def lock():
+        called["locked"] = True
+        pm.locked = True
+
+    pm.lock_vault = lock
+    pm.auth_guard = manager.AuthGuard(pm, time_fn=lambda: now["val"])
+
+    now["val"] = 0.4
+    pm.update_activity()
+    assert not called
+
+    now["val"] = 1.1
+    pm.update_activity()
+    assert called["locked"] is True
diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py
index eb71f620..3d519931 100644
--- a/src/tests/test_typer_cli.py
+++ b/src/tests/test_typer_cli.py
@@ -153,6 +153,23 @@ def test_vault_lock(monkeypatch):
     assert pm.locked is True
 
 
+def test_root_lock(monkeypatch):
+    called = {}
+
+    def lock():
+        called["locked"] = True
+        pm.locked = True
+
+    pm = SimpleNamespace(
+        lock_vault=lock, locked=False, select_fingerprint=lambda fp: None
+    )
+    monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
+    result = runner.invoke(app, ["lock"])
+    assert result.exit_code == 0
+    assert called.get("locked") is True
+    assert pm.locked is True
+
+
 def test_vault_reveal_parent_seed(monkeypatch, tmp_path):
     called = {}
 

From 29690d7c7b65f12940a7a45e2d435478709ceb34 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 16:05:00 -0400
Subject: [PATCH 33/75] Add vault profile export/import

---
 docs/docs/content/index.md              |  4 +-
 src/seedpass/cli.py                     | 12 +++---
 src/seedpass/core/api.py                | 20 ++++++++++
 src/tests/test_cli_doc_examples.py      |  2 +
 src/tests/test_profile_export_import.py | 33 +++++++++++++++++
 src/tests/test_typer_cli.py             | 49 +++++++++++--------------
 6 files changed, 85 insertions(+), 35 deletions(-)
 create mode 100644 src/tests/test_profile_export_import.py

diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md
index a4d072a5..35b069dd 100644
--- a/docs/docs/content/index.md
+++ b/docs/docs/content/index.md
@@ -207,10 +207,10 @@ create a backup:
 seedpass
 
 # Export your index
-seedpass export --file "~/seedpass_backup.json"
+seedpass vault export --file "~/seedpass_backup.json"
 
 # Later you can restore it
-seedpass import --file "~/seedpass_backup.json"
+seedpass vault import --file "~/seedpass_backup.json"
 # Import also performs a Nostr sync to pull any changes
 
 # Quickly find or retrieve entries
diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py
index 4b9a917a..df7f56c0 100644
--- a/src/seedpass/cli.py
+++ b/src/seedpass/cli.py
@@ -14,8 +14,6 @@ from seedpass.core.api import (
     ConfigService,
     UtilityService,
     NostrService,
-    VaultExportRequest,
-    VaultImportRequest,
     ChangePasswordRequest,
     UnlockRequest,
     BackupParentSeedRequest,
@@ -402,9 +400,10 @@ def entry_export_totp(
 def vault_export(
     ctx: typer.Context, file: str = typer.Option(..., help="Output file")
 ) -> None:
-    """Export the vault."""
+    """Export the vault profile to an encrypted file."""
     vault_service, _profile, _sync = _get_services(ctx)
-    vault_service.export_vault(VaultExportRequest(path=Path(file)))
+    data = vault_service.export_profile()
+    Path(file).write_bytes(data)
     typer.echo(str(file))
 
 
@@ -412,9 +411,10 @@ def vault_export(
 def vault_import(
     ctx: typer.Context, file: str = typer.Option(..., help="Input file")
 ) -> None:
-    """Import a vault from an encrypted JSON file."""
+    """Import a vault profile from an encrypted file."""
     vault_service, _profile, _sync = _get_services(ctx)
-    vault_service.import_vault(VaultImportRequest(path=Path(file)))
+    data = Path(file).read_bytes()
+    vault_service.import_profile(data)
     typer.echo(str(file))
 
 
diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py
index 6acb9588..70788536 100644
--- a/src/seedpass/core/api.py
+++ b/src/seedpass/core/api.py
@@ -10,6 +10,7 @@ allow easy validation and documentation.
 from pathlib import Path
 from threading import Lock
 from typing import List, Optional, Dict
+import json
 
 from pydantic import BaseModel
 
@@ -102,6 +103,25 @@ class VaultService:
             self._manager.handle_import_database(req.path)
             self._manager.sync_vault()
 
+    def export_profile(self) -> bytes:
+        """Return encrypted profile data for backup."""
+
+        with self._lock:
+            data = self._manager.vault.load_index()
+            payload = json.dumps(data, sort_keys=True, separators=(",", ":")).encode(
+                "utf-8"
+            )
+            return self._manager.vault.encryption_manager.encrypt_data(payload)
+
+    def import_profile(self, data: bytes) -> None:
+        """Restore a profile from ``data`` and sync."""
+
+        with self._lock:
+            decrypted = self._manager.vault.encryption_manager.decrypt_data(data)
+            index = json.loads(decrypted.decode("utf-8"))
+            self._manager.vault.save_index(index)
+            self._manager.sync_vault()
+
     def change_password(self, req: ChangePasswordRequest) -> None:
         """Change the master password."""
 
diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py
index 7f4ce825..1518261c 100644
--- a/src/tests/test_cli_doc_examples.py
+++ b/src/tests/test_cli_doc_examples.py
@@ -84,7 +84,9 @@ def load_doc_commands() -> list[str]:
     cmds = set(re.findall(r"`seedpass ([^`<>]+)`", text))
     cmds = {c for c in cmds if "<" not in c and ">" not in c}
     cmds.discard("vault export")
+    cmds.discard("vault export --file backup.json")
     cmds.discard("vault import")
+    cmds.discard("vault import --file backup.json")
     return sorted(cmds)
 
 
diff --git a/src/tests/test_profile_export_import.py b/src/tests/test_profile_export_import.py
new file mode 100644
index 00000000..0929d952
--- /dev/null
+++ b/src/tests/test_profile_export_import.py
@@ -0,0 +1,33 @@
+from pathlib import Path
+from types import SimpleNamespace
+
+from seedpass.core.api import VaultService
+from helpers import create_vault, TEST_SEED, TEST_PASSWORD
+
+
+def test_profile_export_import_round_trip(tmp_path):
+    dir1 = tmp_path / "a"
+    vault1, _ = create_vault(dir1, TEST_SEED, TEST_PASSWORD)
+    data = {
+        "schema_version": 4,
+        "entries": {"0": {"label": "example", "type": "password"}},
+    }
+    vault1.save_index(data)
+    pm1 = SimpleNamespace(vault=vault1, sync_vault=lambda: None)
+    service1 = VaultService(pm1)
+    blob = service1.export_profile()
+
+    dir2 = tmp_path / "b"
+    vault2, _ = create_vault(dir2, TEST_SEED, TEST_PASSWORD)
+    vault2.save_index({"schema_version": 4, "entries": {}})
+    called = {}
+
+    def sync():
+        called["synced"] = True
+
+    pm2 = SimpleNamespace(vault=vault2, sync_vault=sync)
+    service2 = VaultService(pm2)
+    service2.import_profile(blob)
+
+    assert called.get("synced") is True
+    assert vault2.load_index() == data
diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py
index 3d519931..c2db4dac 100644
--- a/src/tests/test_typer_cli.py
+++ b/src/tests/test_typer_cli.py
@@ -68,58 +68,53 @@ def test_entry_get_password(monkeypatch):
 def test_vault_export(monkeypatch, tmp_path):
     called = {}
 
-    def export_db(path):
-        called["path"] = path
+    def export_profile(self):
+        called["export"] = True
+        return b"data"
 
-    pm = SimpleNamespace(
-        handle_export_database=export_db, select_fingerprint=lambda fp: None
-    )
-    monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
+    monkeypatch.setattr(cli.VaultService, "export_profile", export_profile)
+    monkeypatch.setattr(cli, "PasswordManager", lambda: SimpleNamespace())
     out_path = tmp_path / "out.json"
     result = runner.invoke(app, ["vault", "export", "--file", str(out_path)])
     assert result.exit_code == 0
-    assert called["path"] == out_path
+    assert called.get("export") is True
+    assert out_path.read_bytes() == b"data"
 
 
 def test_vault_import(monkeypatch, tmp_path):
     called = {}
 
-    def import_db(path):
-        called["path"] = path
+    def import_profile(self, data):
+        called["data"] = data
 
-    pm = SimpleNamespace(
-        handle_import_database=import_db,
-        select_fingerprint=lambda fp: None,
-        sync_vault=lambda: None,
-    )
-    monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
+    monkeypatch.setattr(cli.VaultService, "import_profile", import_profile)
+    monkeypatch.setattr(cli, "PasswordManager", lambda: SimpleNamespace())
     in_path = tmp_path / "in.json"
-    in_path.write_text("{}")
+    in_path.write_bytes(b"inp")
     result = runner.invoke(app, ["vault", "import", "--file", str(in_path)])
     assert result.exit_code == 0
-    assert called["path"] == in_path
+    assert called["data"] == b"inp"
 
 
 def test_vault_import_triggers_sync(monkeypatch, tmp_path):
     called = {}
 
-    def import_db(path):
-        called["path"] = path
+    def import_profile(self, data):
+        called["data"] = data
+        self._manager.sync_vault()
 
-    def sync():
+    def sync_vault():
         called["sync"] = True
 
-    pm = SimpleNamespace(
-        handle_import_database=import_db,
-        sync_vault=sync,
-        select_fingerprint=lambda fp: None,
+    monkeypatch.setattr(cli.VaultService, "import_profile", import_profile)
+    monkeypatch.setattr(
+        cli, "PasswordManager", lambda: SimpleNamespace(sync_vault=sync_vault)
     )
-    monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
     in_path = tmp_path / "in.json"
-    in_path.write_text("{}")
+    in_path.write_bytes(b"inp")
     result = runner.invoke(app, ["vault", "import", "--file", str(in_path)])
     assert result.exit_code == 0
-    assert called["path"] == in_path
+    assert called.get("data") == b"inp"
     assert called.get("sync") is True
 
 

From 724c0b883fcaa59074d4bf4725a422ce566c30d5 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 17:02:05 -0400
Subject: [PATCH 34/75] Add pubsub event system and integrate sync
 notifications

---
 src/seedpass/core/api.py     |  3 +++
 src/seedpass/core/manager.py | 13 +++++++++++--
 src/seedpass/core/pubsub.py  | 27 +++++++++++++++++++++++++++
 src/tests/test_pubsub.py     | 28 ++++++++++++++++++++++++++++
 4 files changed, 69 insertions(+), 2 deletions(-)
 create mode 100644 src/seedpass/core/pubsub.py
 create mode 100644 src/tests/test_pubsub.py

diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py
index 70788536..d51a21e0 100644
--- a/src/seedpass/core/api.py
+++ b/src/seedpass/core/api.py
@@ -15,6 +15,7 @@ import json
 from pydantic import BaseModel
 
 from .manager import PasswordManager
+from .pubsub import bus
 
 
 class VaultExportRequest(BaseModel):
@@ -202,7 +203,9 @@ class SyncService:
         """Publish the vault to Nostr and return event info."""
 
         with self._lock:
+            bus.publish("sync_started")
             result = self._manager.sync_vault()
+            bus.publish("sync_finished", result)
         if not result:
             return None
         return SyncResponse(**result)
diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py
index a707391b..f6d1d786 100644
--- a/src/seedpass/core/manager.py
+++ b/src/seedpass/core/manager.py
@@ -33,6 +33,7 @@ from .vault import Vault
 from .portable_backup import export_backup, import_backup
 from .totp import TotpManager
 from .entry_types import EntryType
+from .pubsub import bus
 from utils.key_derivation import (
     derive_key_from_parent_seed,
     derive_key_from_password,
@@ -1243,7 +1244,9 @@ class PasswordManager:
 
         def _worker() -> None:
             try:
-                asyncio.run(self.sync_vault_async(alt_summary=alt_summary))
+                bus.publish("sync_started")
+                result = asyncio.run(self.sync_vault_async(alt_summary=alt_summary))
+                bus.publish("sync_finished", result)
             except Exception as exc:
                 logging.error(f"Background vault sync failed: {exc}", exc_info=True)
 
@@ -1252,7 +1255,13 @@ class PasswordManager:
         except RuntimeError:
             threading.Thread(target=_worker, daemon=True).start()
         else:
-            asyncio.create_task(self.sync_vault_async(alt_summary=alt_summary))
+
+            async def _async_worker() -> None:
+                bus.publish("sync_started")
+                result = await self.sync_vault_async(alt_summary=alt_summary)
+                bus.publish("sync_finished", result)
+
+            asyncio.create_task(_async_worker())
 
     async def attempt_initial_sync_async(self) -> bool:
         """Attempt to download the initial vault snapshot from Nostr.
diff --git a/src/seedpass/core/pubsub.py b/src/seedpass/core/pubsub.py
new file mode 100644
index 00000000..fec4483e
--- /dev/null
+++ b/src/seedpass/core/pubsub.py
@@ -0,0 +1,27 @@
+from collections import defaultdict
+from typing import Callable, Dict, List, Any
+
+
+class PubSub:
+    """Simple in-process event bus using the observer pattern."""
+
+    def __init__(self) -> None:
+        self._subscribers: Dict[str, List[Callable[..., None]]] = defaultdict(list)
+
+    def subscribe(self, event: str, callback: Callable[..., None]) -> None:
+        """Register ``callback`` to be invoked when ``event`` is published."""
+        self._subscribers[event].append(callback)
+
+    def unsubscribe(self, event: str, callback: Callable[..., None]) -> None:
+        """Unregister ``callback`` from ``event`` notifications."""
+        if callback in self._subscribers.get(event, []):
+            self._subscribers[event].remove(callback)
+
+    def publish(self, event: str, *args: Any, **kwargs: Any) -> None:
+        """Notify all subscribers of ``event`` passing ``*args`` and ``**kwargs``."""
+        for callback in list(self._subscribers.get(event, [])):
+            callback(*args, **kwargs)
+
+
+# Global bus instance for convenience
+bus = PubSub()
diff --git a/src/tests/test_pubsub.py b/src/tests/test_pubsub.py
new file mode 100644
index 00000000..7cf21c97
--- /dev/null
+++ b/src/tests/test_pubsub.py
@@ -0,0 +1,28 @@
+from seedpass.core.pubsub import PubSub
+
+
+def test_subscribe_and_publish():
+    bus = PubSub()
+    calls = []
+
+    def handler(arg):
+        calls.append(arg)
+
+    bus.subscribe("event", handler)
+    bus.publish("event", 123)
+
+    assert calls == [123]
+
+
+def test_unsubscribe():
+    bus = PubSub()
+    calls = []
+
+    def handler():
+        calls.append(True)
+
+    bus.subscribe("event", handler)
+    bus.unsubscribe("event", handler)
+    bus.publish("event")
+
+    assert calls == []

From 5fce7836d9303eba74fd3e822bd0f45fff80376d Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 17:38:07 -0400
Subject: [PATCH 35/75] Remove binary docs images

---
 .../01-getting-started/06-gui_adapter.md      |  12 ++
 src/seedpass/core/manager.py                  |   1 +
 src/seedpass_gui/app.py                       | 117 +++++++++++++++++-
 src/tests/test_gui_features.py                |  78 ++++++++++++
 src/tests/test_gui_headless.py                |  15 ++-
 src/tests/test_vault_lock_event.py            |  28 +++++
 6 files changed, 244 insertions(+), 7 deletions(-)
 create mode 100644 src/tests/test_gui_features.py
 create mode 100644 src/tests/test_vault_lock_event.py

diff --git a/docs/docs/content/01-getting-started/06-gui_adapter.md b/docs/docs/content/01-getting-started/06-gui_adapter.md
index e9303075..54215c7b 100644
--- a/docs/docs/content/01-getting-started/06-gui_adapter.md
+++ b/docs/docs/content/01-getting-started/06-gui_adapter.md
@@ -2,6 +2,7 @@
 
 SeedPass ships with a proof-of-concept graphical interface built using [BeeWare](https://beeware.org). The GUI interacts with the same core services as the CLI by instantiating wrappers around `PasswordManager`.
 
+
 ## Getting Started with the GUI
 
 After installing the project dependencies, launch the desktop interface with one
@@ -104,3 +105,14 @@ def start_background_vault_sync(self, alt_summary: str | None = None) -> None:
 ```
 
 This approach ensures synchronization happens asynchronously whether the GUI is running inside or outside an existing event loop.
+
+## Relay Manager and Status Bar
+
+The *Relays* button opens a dialog for adding or removing Nostr relay URLs. The
+status bar at the bottom of the main window shows when the last synchronization
+completed. It updates automatically when `sync_started` and `sync_finished`
+events are published on the internal pubsub bus.
+
+When a ``vault_locked`` event is emitted, the GUI automatically returns to the
+lock screen so the session can be reopened with the master password.
+
diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py
index f6d1d786..5ae332aa 100644
--- a/src/seedpass/core/manager.py
+++ b/src/seedpass/core/manager.py
@@ -304,6 +304,7 @@ class PasswordManager:
         self.nostr_client = None
         self.config_manager = None
         self.locked = True
+        bus.publish("vault_locked")
 
     def unlock_vault(self, password: Optional[str] = None) -> float:
         """Unlock the vault using the provided ``password``.
diff --git a/src/seedpass_gui/app.py b/src/seedpass_gui/app.py
index f1578f13..a859afea 100644
--- a/src/seedpass_gui/app.py
+++ b/src/seedpass_gui/app.py
@@ -5,18 +5,25 @@ from toga.style import Pack
 from toga.style.pack import COLUMN, ROW
 
 from seedpass.core.manager import PasswordManager
+import time
+
 from seedpass.core.api import (
     VaultService,
     EntryService,
+    NostrService,
     UnlockRequest,
 )
+from seedpass.core.pubsub import bus
 
 
 class LockScreenWindow(toga.Window):
     """Window prompting for the master password."""
 
     def __init__(
-        self, controller: SeedPassApp, vault: VaultService, entries: EntryService
+        self,
+        controller: SeedPassApp,
+        vault: VaultService,
+        entries: EntryService,
     ) -> None:
         super().__init__("Unlock Vault")
         # Store a reference to the SeedPass application instance separately from
@@ -45,7 +52,12 @@ class LockScreenWindow(toga.Window):
         except Exception as exc:  # pragma: no cover - GUI error handling
             self.message.text = str(exc)
             return
-        main = MainWindow(self.controller, self.vault, self.entries)
+        main = MainWindow(
+            self.controller,
+            self.vault,
+            self.entries,
+            self.controller.nostr_service,
+        )
         self.controller.main_window = main
         main.show()
         self.close()
@@ -55,14 +67,23 @@ class MainWindow(toga.Window):
     """Main application window showing vault entries."""
 
     def __init__(
-        self, controller: SeedPassApp, vault: VaultService, entries: EntryService
+        self,
+        controller: SeedPassApp,
+        vault: VaultService,
+        entries: EntryService,
+        nostr: NostrService,
     ) -> None:
-        super().__init__("SeedPass")
+        super().__init__("SeedPass", on_close=self.cleanup)
         # ``Window.app`` is reserved for the Toga ``App`` instance. Store the
         # SeedPass application reference separately.
         self.controller = controller
         self.vault = vault
         self.entries = entries
+        self.nostr = nostr
+        bus.subscribe("sync_started", self.sync_started)
+        bus.subscribe("sync_finished", self.sync_finished)
+        bus.subscribe("vault_locked", self.vault_locked)
+        self.last_sync = None
 
         self.table = toga.Table(
             headings=["ID", "Label", "Username", "URL"], style=Pack(flex=1)
@@ -71,15 +92,20 @@ class MainWindow(toga.Window):
         add_button = toga.Button("Add", on_press=self.add_entry)
         edit_button = toga.Button("Edit", on_press=self.edit_entry)
         search_button = toga.Button("Search", on_press=self.search_entries)
+        relay_button = toga.Button("Relays", on_press=self.manage_relays)
 
         button_box = toga.Box(style=Pack(direction=ROW, padding_top=5))
         button_box.add(add_button)
         button_box.add(edit_button)
         button_box.add(search_button)
+        button_box.add(relay_button)
+
+        self.status = toga.Label("Last sync: never", style=Pack(padding_top=5))
 
         box = toga.Box(style=Pack(direction=COLUMN, padding=10))
         box.add(self.table)
         box.add(button_box)
+        box.add(self.status)
         self.content = box
 
         self.refresh_entries()
@@ -105,6 +131,28 @@ class MainWindow(toga.Window):
         dlg = SearchDialog(self)
         dlg.show()
 
+    def manage_relays(self, widget: toga.Widget) -> None:
+        dlg = RelayManagerDialog(self, self.nostr)
+        dlg.show()
+
+    # --- PubSub callbacks -------------------------------------------------
+    def sync_started(self, *args: object, **kwargs: object) -> None:
+        self.status.text = "Syncing..."
+
+    def sync_finished(self, *args: object, **kwargs: object) -> None:
+        self.last_sync = time.strftime("%H:%M:%S")
+        self.status.text = f"Last sync: {self.last_sync}"
+
+    def vault_locked(self, *args: object, **kwargs: object) -> None:
+        self.close()
+        self.controller.main_window = None
+        self.controller.lock_window.show()
+
+    def cleanup(self, *args: object, **kwargs: object) -> None:
+        bus.unsubscribe("sync_started", self.sync_started)
+        bus.unsubscribe("sync_finished", self.sync_finished)
+        bus.unsubscribe("vault_locked", self.vault_locked)
+
 
 class EntryDialog(toga.Window):
     """Dialog for adding or editing an entry."""
@@ -187,6 +235,62 @@ class SearchDialog(toga.Window):
         self.close()
 
 
+class RelayManagerDialog(toga.Window):
+    """Dialog for managing relay URLs."""
+
+    def __init__(self, main: MainWindow, nostr: NostrService) -> None:
+        super().__init__("Relays")
+        self.main = main
+        self.nostr = nostr
+
+        self.table = toga.Table(headings=["Index", "URL"], style=Pack(flex=1))
+        self.new_input = toga.TextInput(style=Pack(flex=1))
+        add_btn = toga.Button("Add", on_press=self.add_relay)
+        remove_btn = toga.Button("Remove", on_press=self.remove_relay)
+        self.message = toga.Label("", style=Pack(color="red"))
+
+        box = toga.Box(style=Pack(direction=COLUMN, padding=20))
+        box.add(self.table)
+        form = toga.Box(style=Pack(direction=ROW, padding_top=5))
+        form.add(self.new_input)
+        form.add(add_btn)
+        form.add(remove_btn)
+        box.add(form)
+        box.add(self.message)
+        self.content = box
+
+        self.refresh()
+
+    def refresh(self) -> None:
+        self.table.data = []
+        for i, url in enumerate(self.nostr.list_relays(), start=1):
+            self.table.data.append((i, url))
+
+    def add_relay(self, widget: toga.Widget) -> None:
+        url = self.new_input.value or ""
+        if not url:
+            return
+        try:
+            self.nostr.add_relay(url)
+        except Exception as exc:  # pragma: no cover - pass errors
+            self.message.text = str(exc)
+            return
+        self.new_input.value = ""
+        self.refresh()
+
+    def remove_relay(self, widget: toga.Widget, *, index: int | None = None) -> None:
+        if index is None:
+            if self.table.selection is None:
+                return
+            index = int(self.table.selection[0])
+        try:
+            self.nostr.remove_relay(index)
+        except Exception as exc:  # pragma: no cover - pass errors
+            self.message.text = str(exc)
+            return
+        self.refresh()
+
+
 def build() -> SeedPassApp:
     """Return a configured :class:`SeedPassApp` instance."""
     return SeedPassApp(formal_name="SeedPass", app_id="org.seedpass.gui")
@@ -197,8 +301,11 @@ class SeedPassApp(toga.App):
         pm = PasswordManager()
         self.vault_service = VaultService(pm)
         self.entry_service = EntryService(pm)
+        self.nostr_service = NostrService(pm)
         self.lock_window = LockScreenWindow(
-            self, self.vault_service, self.entry_service
+            self,
+            self.vault_service,
+            self.entry_service,
         )
         self.main_window = None
         self.lock_window.show()
diff --git a/src/tests/test_gui_features.py b/src/tests/test_gui_features.py
new file mode 100644
index 00000000..13769a57
--- /dev/null
+++ b/src/tests/test_gui_features.py
@@ -0,0 +1,78 @@
+import os
+import toga
+import types
+
+import pytest
+
+pytestmark = pytest.mark.desktop
+
+from seedpass.core.pubsub import bus
+from seedpass_gui.app import MainWindow, RelayManagerDialog
+
+
+class DummyNostr:
+    def __init__(self):
+        self.relays = ["wss://a"]
+
+    def list_relays(self):
+        return list(self.relays)
+
+    def add_relay(self, url):
+        self.relays.append(url)
+
+    def remove_relay(self, idx):
+        self.relays.pop(idx - 1)
+
+
+class DummyEntries:
+    def list_entries(self):
+        return []
+
+    def search_entries(self, q):
+        return []
+
+
+class DummyController:
+    def __init__(self):
+        self.lock_window = types.SimpleNamespace(show=lambda: None)
+        self.main_window = None
+        self.vault_service = None
+        self.entry_service = None
+        self.nostr_service = None
+
+
+@pytest.fixture(autouse=True)
+def set_backend():
+    os.environ["TOGA_BACKEND"] = "toga_dummy"
+    import asyncio
+
+    asyncio.set_event_loop(asyncio.new_event_loop())
+
+
+def test_relay_manager_add_remove():
+    toga.App("T", "o")
+    ctrl = DummyController()
+    nostr = DummyNostr()
+    win = MainWindow(ctrl, None, DummyEntries(), nostr)
+    dlg = RelayManagerDialog(win, nostr)
+    dlg.new_input.value = "wss://b"
+    dlg.add_relay(None)
+    assert nostr.relays == ["wss://a", "wss://b"]
+    dlg.remove_relay(None, index=1)
+    assert nostr.relays == ["wss://b"]
+
+
+def test_status_bar_updates_and_lock():
+    toga.App("T2", "o2")
+    ctrl = DummyController()
+    nostr = DummyNostr()
+    ctrl.lock_window = types.SimpleNamespace(show=lambda: setattr(ctrl, "locked", True))
+    win = MainWindow(ctrl, None, DummyEntries(), nostr)
+    ctrl.main_window = win
+    bus.publish("sync_started")
+    assert win.status.text == "Syncing..."
+    bus.publish("sync_finished")
+    assert "Last sync:" in win.status.text
+    bus.publish("vault_locked")
+    assert getattr(ctrl, "locked", False)
+    assert ctrl.main_window is None
diff --git a/src/tests/test_gui_headless.py b/src/tests/test_gui_headless.py
index 8801064f..ef5977b7 100644
--- a/src/tests/test_gui_headless.py
+++ b/src/tests/test_gui_headless.py
@@ -40,18 +40,29 @@ def setup_module(module):
     asyncio.set_event_loop(asyncio.new_event_loop())
 
 
+class FakeNostr:
+    def list_relays(self):
+        return []
+
+    def add_relay(self, url):
+        pass
+
+    def remove_relay(self, idx):
+        pass
+
+
 def test_unlock_creates_main_window():
     app = toga.App("Test", "org.example")
-    controller = SimpleNamespace(main_window=None)
+    controller = SimpleNamespace(main_window=None, nostr_service=FakeNostr())
     vault = FakeVault()
     entries = FakeEntries()
-
     win = LockScreenWindow(controller, vault, entries)
     win.password_input.value = "pw"
     win.handle_unlock(None)
 
     assert vault.called
     assert isinstance(controller.main_window, MainWindow)
+    controller.main_window.cleanup()
 
 
 def test_entrydialog_add_calls_service():
diff --git a/src/tests/test_vault_lock_event.py b/src/tests/test_vault_lock_event.py
new file mode 100644
index 00000000..2f2aba0e
--- /dev/null
+++ b/src/tests/test_vault_lock_event.py
@@ -0,0 +1,28 @@
+from seedpass.core.manager import PasswordManager
+from seedpass.core.pubsub import bus
+
+
+def test_lock_vault_publishes_event():
+    pm = PasswordManager.__new__(PasswordManager)
+    pm.entry_manager = None
+    pm.encryption_manager = None
+    pm.password_generator = None
+    pm.backup_manager = None
+    pm.vault = None
+    pm.bip85 = None
+    pm.nostr_client = None
+    pm.config_manager = None
+    pm.locked = False
+    pm._parent_seed_secret = None
+
+    called = []
+
+    def handler():
+        called.append(True)
+
+    bus.subscribe("vault_locked", handler)
+    pm.lock_vault()
+    bus.unsubscribe("vault_locked", handler)
+
+    assert pm.locked
+    assert called == [True]

From 615be7d325f3e6f0ffc8386ad17a5a5d5ef85583 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 18:01:05 -0400
Subject: [PATCH 36/75] Add Briefcase build workflow and update README

---
 .github/workflows/briefcase.yml | 27 +++++++++++++++++++++++++++
 README.md                       |  4 ++++
 2 files changed, 31 insertions(+)
 create mode 100644 .github/workflows/briefcase.yml

diff --git a/.github/workflows/briefcase.yml b/.github/workflows/briefcase.yml
new file mode 100644
index 00000000..99d6a6f2
--- /dev/null
+++ b/.github/workflows/briefcase.yml
@@ -0,0 +1,27 @@
+name: Build GUI
+
+on:
+  push:
+    tags:
+      - 'seedpass-gui*'
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-python@v4
+        with:
+          python-version: '3.11'
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r src/requirements.txt
+          pip install briefcase
+      - name: Build with Briefcase
+        run: briefcase build
+      - name: Upload artifacts
+        uses: actions/upload-artifact@v4
+        with:
+          name: seedpass-gui
+          path: dist/**
diff --git a/README.md b/README.md
index 84951ef9..9b671ff7 100644
--- a/README.md
+++ b/README.md
@@ -603,6 +603,10 @@ You can also produce packaged installers for the GUI with BeeWare's Briefcase:
 briefcase build
 ```
 
+Pre-built installers are published for each `seedpass-gui` tag. Visit the
+project's **Actions** or **Releases** page on GitHub to download the latest
+package for your platform.
+
 The standalone executable will appear in the `dist/` directory. This process works on Windows, macOS and Linux but you must build on each platform for a native binary.
 
 ## Security Considerations

From a6e18ae9c5ae5c1640cf07edc8e6188d42bf3aa6 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 21:56:23 -0400
Subject: [PATCH 37/75] update

---
 dev-plan.md            |  93 ---------------------------------
 post-refactor-to-do.md |  78 ----------------------------
 refactor.md            | 113 -----------------------------------------
 3 files changed, 284 deletions(-)
 delete mode 100644 dev-plan.md
 delete mode 100644 post-refactor-to-do.md
 delete mode 100644 refactor.md

diff --git a/dev-plan.md b/dev-plan.md
deleted file mode 100644
index d3aa788e..00000000
--- a/dev-plan.md
+++ /dev/null
@@ -1,93 +0,0 @@
-### SeedPass Road-to-1.0 — Detailed Development Plan
-
-*(Assumes today = 1 July 2025, team of 1-3 devs, weekly release cadence)*
-
-| Phase                                | Goal                                                                      | Key Deliverables                                                                                                                                                                                                                                                                                                                                                                      | Target Window             |
-| ------------------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
-| **0 – Vision Lock-in**               | Be explicit about where you’re going so every later trade-off is easy.    | • 2-page “north-star” doc covering product scope, security promises, platforms, and **“CLI is source of truth”** principle. 
• Public roadmap Kanban board. | **Week 0** | -| **1 – Package-ready Codebase** | Turn loose `src/` tree into a pip-installable library + console script. | • `pyproject.toml` with PEP-621 metadata, `setuptools-scm` dynamic version.
• Restructure to `seedpass/` (or keep `src/` but list `packages = ["seedpass"]`).
• Entry-point: `seedpass = "seedpass.main:cli"`.
• Dev extras: `pytest-cov`, `ruff`, `mypy`, `pre-commit`.
• Split pure business logic from I/O (e.g., encryption, BIP-85, vault ops) so GUI can reuse. | **Weeks 0-2** | -| **2 – Local Quality Net** | Fail fast before CI runs. | • `make test` / `tox` quick matrix (3.10–3.12).
• 90 % line coverage gate.
• Static checks in pre-commit (black, ruff, mypy). | **Weeks 1-3** | -| **3 – CI / Release Automation** | One Git tag → everything ships. | • GitHub Actions matrix (Ubuntu, macOS, Windows).
• Steps: install → unit tests → build wheels (`python -m build`) → PyInstaller one-file artefacts → upload to Release.
• Secrets for PyPI / code-signing left empty until 1.0. | **Weeks 2-4** | -| **4 – OS-Native Packages** | Users can “apt install / brew install / flatpak install / download .exe”. | **Linux** • `stdeb` → `.deb`, `reprepro` mini-APT repo.
**Flatpak** • YAML manifest + GitHub Action to build & push to Flathub beta repo.
**Windows** • PyInstaller `--onefile` → NSIS installer.
**macOS** • Briefcase → notarised `.pkg` or `.dmg` (signing cert later). | **Weeks 4-8** | -| **5 – Experimental GUI Track** | Ship a GUI **without** slowing CLI velocity. | • Decide stack (recommend **Textual** first; upgrade later to Toga or PySide).
• Create `seedpass.gui` package calling existing APIs; flag with `--gui`.
• Feature flag via env var `SEEDPASS_GUI=1` or CLI switch.
• Separate workflow that builds GUI artefacts, but does **not** block CLI releases. | **Weeks 6-12** (parallel) | -| **6 – Plugin / Extensibility Layer** | Keep core slim while allowing future features. | • Define `entry_points={"seedpass.plugins": …}`.
• Document simple example plugin (e.g., custom password rule).
• Load plugins lazily to avoid startup cost. | **Weeks 10-14** | -| **7 – Security & Hardening** | Turn security assumptions into guarantees before 1.0 | • SAST scan (Bandit, Semgrep).
• Threat-model doc: key-storage, BIP-85 determinism, Nostr backup flow.
• Repro-build check for PyInstaller artefacts.
• Signed releases (Sigstore, minisign). | **Weeks 12-16** | -| **8 – 1.0 Launch Prep** | Final polish + docs. | • User manual (MkDocs, `docs.seedpass.org`).
• In-app `--check-update` hitting GitHub API.
• Blog post & template release notes. | **Weeks 16-18** | - ---- - -### Ongoing Practices to Keep Development Nimble - -| Practice | What to do | -| ----------------------- | ------------------------------------------------------------------------------------------- | -| **Dynamic versioning** | Keep `version` dynamic via `setuptools-scm` / `hatch-vcs`; tag and push – nothing else. | -| **Trunk-based dev** | Short-lived branches, PRs < 300 LOC; merge when tests pass. | -| **Feature flags** | `seedpass.config.is_enabled("X")` so unfinished work can ship dark. | -| **Fast feedback loops** | Local editable install; `invoke run --watch` (or `uvicorn --reload` for GUI) to hot-reload. | -| **Weekly beta release** | Even during heavy GUI work, cut “beta” tags weekly; real users shake out regressions early. | - ---- - -### First 2-Week Sprint (Concrete To-Dos) - -1. **Bootstrap packaging** - - ```bash - pip install --upgrade pip build setuptools_scm - poetry init # if you prefer Poetry, else stick with setuptools - ``` - - Add `pyproject.toml`, move code to `seedpass/`. - -2. **Console entry-point** - In `seedpass/__main__.py` add `from .main import cli; cli()`. - -3. **Editable dev install** - `pip install -e .[dev]` → run `seedpass --help`. - -4. **Set up pre-commit** - `pre-commit install` with ruff + black + mypy hooks. - -5. **GitHub Action skeleton** (`.github/workflows/ci.yml`) - - ```yaml - jobs: - test: - strategy: - matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.12', '3.11'] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: {python-version: ${{ matrix.python-version }}} - - run: pip install --upgrade pip - - run: pip install -e .[dev] - - run: pytest -n auto - ``` - -6. **Smoke PyInstaller locally** - `pyinstaller --onefile seedpass/main.py` – fix missing data/hooks; check binary runs. - -When that’s green, cut tag `v0.1.0-beta` and let CI build artefacts automatically. - ---- - -### Choosing the GUI Path (decision by Week 6) - -| If you value… | Choose | -| ---------------------------------- | ---------------------------- | -| Terminal-first UX, live coding | **Textual (Rich-TUI)** | -| Native look, single code base | **Toga / Briefcase** | -| Advanced widgets, designer tooling | **PySide-6 / Qt for Python** | - -Prototype one screen (vault list + “Add” dialog) and benchmark bundle size + startup time with PyInstaller before committing. - ---- - -## Recap - -* **Packaging & CI first** – lets every future feature ride an established release train. -* **GUI lives in its own layer** – CLI stays stable; dev cycles remain quick. -* **Security & signing** land after functionality is stable, before v1.0 marketing push. - -Follow the phase table, keep weekly betas flowing, and you’ll reach a polished, installer-ready, GUI-enhanced 1.0 in roughly four months without sacrificing day-to-day agility. diff --git a/post-refactor-to-do.md b/post-refactor-to-do.md deleted file mode 100644 index 62a038af..00000000 --- a/post-refactor-to-do.md +++ /dev/null @@ -1,78 +0,0 @@ ---- - -# SeedPass Feature Back‑Log (v2) - -> **Encryption invariant**   Everything at rest **and** in export remains cipher‑text that ultimately derives from the **profile master‑password + parent seed**. No unencrypted payload leaves the vault. -> -> **Surface rule**   UI layers (CLI, GUI, future mobile) may *display* decrypted data **after** user unlock, but must never write plaintext to disk or network. - ---- - -## Track vocabulary - -| Label | Meaning | -| ------------ | ------------------------------------------------------------------------------ | -| **Core API** | `seedpass.api` – headless services consumed by CLI / GUI | -| **Profile** | A fingerprint‑scoped vault: parent‑seed + hashed pw + entries | -| **Entry** | One encrypted JSON blob on disk plus Nostr snapshot chunks and delta events | -| **GUI MVP** | Desktop app built with PySide 6 announced in the v2 roadmap | - ---- - -## Phase A  •  Core‑level enhancements (blockers for GUI) - -|  Prio  | Feature | Notes | -| ------ | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -|  🔥 | **Encrypted Search API** | • `VaultService.search(query:str, *, kinds=None) -> List[EntryMeta]`
• Decrypt *only* whitelisted meta‑fields per `kind` (title, username, url, tags) for in‑memory matching. | -|  🔥 | **Rich Listing / Sort / Filter** | • `list_entries(sort_by="updated", kind="note")`
• Sorting by `title` must decrypt that field on‑the‑fly. | -|  🔥 | **Custom Relay Set (per profile)** | • `StateManager.state["relays"]: List[str]`
• CRUD CLI commands & GUI dialog.
• `NostrClient` reads from state at instantiation. | -|  ⚡ | **Session Lock & Idle Timeout** | • Config `SESSION_TIMEOUT` (default 15 min).
• `AuthGuard` clears in‑memory keys & seeds.
• CLI `seedpass lock` + GUI menu “Lock vault”. | - -**Exit‑criteria** : All functions green in CI, consumed by both CLI (Typer) *and* a minimal Qt test harness. - ---- - -## Phase B  •  Data Portability (encrypted only) - -|  Prio  | Feature | Notes | | -| ------ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -|  ⭐ | **Encrypted Profile Export** | • CLI `seedpass export --out myprofile.enc`
• Serialise *encrypted* entry files → single JSON wrapper → `EncryptionManager.encrypt_data()`
• Always require active profile unlock. | | -|  ⭐ | **Encrypted Profile Import / Merge** | • CLI \`seedpass import myprofile.enc \[--strategy skip | overwrite-newer]`
• Verify fingerprint match before ingest.
• Conflict policy pluggable; default `skip\`. | - ---- - -## Phase C  •  Advanced secrets & sync - -|  Prio  | Feature | Notes | -| ------ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -|  ◇ | **TOTP entry kind** | • `kind="totp_secret"` fields: title, issuer, username, secret\_key
• `secret_key` encrypted; handler uses `pyotp` to show current code. | -|  ◇ | **Manual Conflict Resolver** | • When `checksum` mismatch *and* both sides newer than last sync → prompt user (CLI) or modal (GUI). | - ---- - -## Phase D  •  Desktop GUI MVP (Qt 6) - -*Features here ride on the Core API; keep UI totally stateless.* - -|  Prio  | Feature | Notes | -| ------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | -|  🔥 | **Login Window** | • Unlock profile with master pw.
• Profile switcher drop‑down. | -|  🔥 | **Vault Window** | • Sidebar (Entries, Search, Backups, Settings).
• `QTableView` bound to `VaultService.list_entries()`
• Sort & basic filters built‑in. | -|  🔥 | **Entry Editor Dialog** | • Dynamic form driven by `kinds.py`.
• Add / Edit. | -|  ⭐ | **Sync Status Bar** | • Pulsing icon + last sync timestamp; hooks into `SyncService` bus. | -|  ◇ | **Relay Manager Dialog** | • CRUD & ping test per relay. | - -*Binary packaging (PyInstaller matrix build) is already tracked in the roadmap and is not duplicated here.* - ---- - -## Phase E  •  Later / Research - -• Hardware‑wallet unlock (SLIP‑39 share) -• Background daemon (`seedpassd` + gRPC) -• Mobile companion (Flutter FFI) -• Federated search across multiple profiles - ---- - -**Reminder:** *No plaintext exports, no on‑disk temp files, and no writing decrypted data to Nostr.* Everything funnels through the encryption stack or stays in memory for the current unlocked session only. diff --git a/refactor.md b/refactor.md deleted file mode 100644 index fdbf4573..00000000 --- a/refactor.md +++ /dev/null @@ -1,113 +0,0 @@ -# SeedPass v2 Roadmap — CLI → Desktop GUI - -> **Guiding principles** -> -> 1. **Core-first** – a headless, testable Python package (`seedpass.core`) that is 100 % GUI-agnostic. -> 2. **Thin adapters** – CLI, GUI, and future mobile layers merely call the core API. -> 3. **Stateless UI** – all persistence lives in core services; UI never touches vault files directly. -> 4. **Parity at every step** – CLI must keep working while GUI evolves. - ---- - -## Phase 0 • Tooling Baseline - -| # | Task | Rationale | -| --- | ---------------------------------------------------------------------------------------------- | --------------------------------- | -| 0.1 | ✅ **Adopt `poetry`** (or `hatch`) for builds & dependency pins. | Single-source version + lockfile. | -| 0.2 | ✅ **GitHub Actions**: lint (ruff), type-check (mypy), tests (pytest -q), coverage gate ≥ 85 %. | Prevent regressions. | -| 0.3 | ✅ Pre-commit hooks: ruff –fix, black, isort. | Uniform style. | - ---- - -## Phase 1 • Finalize Core Refactor (CLI still primary) - -> *Most of this is already drafted – here’s what must ship before GUI work starts.* - -| # | Component | Must-have work | -| --- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| 1.1 | **`kinds.py` registry + per-kind handler modules** | import-safe; handler signature `(data,fingerprint,**svc)` | -| 1.2 | **`StateManager`** | JSON file w/ fcntl lock
keys: `last_bip85_idx`, `last_sync_ts` | -| 1.3 | **Checksum inside entry metadata** | `sha256(json.dumps(data,sort_keys=True))` | -| 1.4 | **Replaceable Nostr events** (kind 31111, `d` tag = `"{kindtag}{entry_num}"`) | publish/update/delete tombstone | -| 1.5 | **Per-entry `EntryManager` / `BackupManager`** | Save / load / backup / restore individual encrypted files | -| 1.6 | **CLI rewritten with Typer** | Typer commands map 1-to-1 with core service methods; preserves colours. | -| 1.7 | **Legacy index migration command** | `seedpass migrate-legacy` – idempotent, uses `add_entry()` under the hood. | -| 1.8 | **bcrypt + NFKD master password hash** | Stored per fingerprint. | - -> **Exit-criteria:** end-to-end flow (`add → list → sync → restore`) green in CI and covered by tests. - ---- - -## Phase 2 • Core API Hardening (prep for GUI) - -| # | Task | Deliverable | -| --- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| 2.1 | **Public Service Layer** (`seedpass.api`) | Facade classes:
`VaultService`, `ProfileService`, `SyncService` – *no* CLI / UI imports. | -| 2.2 | **Thread-safe gate** | Re-entrancy locks so GUI threads can call core safely. | -| 2.3 | **Fast in-process event bus** | Simple `pubsub.py` (observer pattern) for GUI to receive progress callbacks (e.g. sync progress, long ops). | -| 2.4 | **Docstrings + pydantic models** | Typed request/response objects → eases RPC later (e.g. REST, gRPC). | -| 2.5 | **Library packaging** | `python -m pip install .` gives importable `seedpass`. | - ---- - -## Phase 3 • Desktop GUI MVP - -| # | Decision | Notes | -| --- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| 3.0 | **Framework: PySide 6 (Qt 6)** | ✓ LGPL, ✓ native look, ✓ Python-first, ✓ WebEngine if needed. | -| 3.1 | **Process model** | *Same* process; GUI thread ↔ core API via signals/slots.
(If we outgrow this, swap to a local gRPC server later.) | -| 3.2 | **UI Skeleton (milestone “Hello Vault”)** | | -| – | `LoginWindow` | master-password prompt → opens default profile | -| – | `VaultWindow` | sidebar (Profiles, Entries, Backups) + stacked views | -| – | `EntryTableView` | QTableView bound to `VaultService.list_entries()` | -| – | `EntryEditorDialog` | Add / Edit forms – field set driven by `kinds.py` | -| – | `SyncStatusBar` | pulse animation + last-sync timestamp | -| 3.3 | **Icons / theming** | Start with Qt-built-in icons; later swap to SVG set. | -| 3.4 | **Packaging** | `PyInstaller --onefile` for Win / macOS / Linux AppImage; GitHub Actions matrix build. | -| 3.5 | **GUI E2E tests** | PyTest + pytest-qt (QtBot) smoke flows; run headless in CI (Xvfb). | - -> **Stretch option:** wrap the same UI in **Tauri** later for a lighter binary (\~5 MB), reusing the core API through a local websocket RPC. - ---- - -## Phase 4 • Unified Workflows & Coverage - -| # | Task | -| --- | --------------------------------------------------------------------------------------- | -| 4.1 | Extend GitHub Actions to build GUI artifacts on every tag. | -| 4.2 | Add synthetic coverage for GUI code paths (QtBot). | -| 4.3 | Nightly job: spin up headless GUI, run `sync` against test relay, assert no exceptions. | - ---- - -## Phase 5 • Future-Proofing (post-GUI v1) - -| Idea | Sketch | -| -------------------------- | ----------------------------------------------------------------------------------------- | -| **Background daemon** | Optional `seedpassd` exposing Unix socket + JSON-RPC; both CLI & GUI become thin clients. | -| **Hardware-wallet unlock** | Replace master password with HWW + SLIP-39 share; requires PyUSB bridge. | -| **Mobile companion app** | Reuse core via BeeWare or Flutter FFI; sync over Nostr only (no local vault). | -| **End-to-end test farm** | dedicated relay docker-compose + pytest-subprocess to fake flaky relays. | - ---- - -## Deliverables Checklist - -* [ ] Core refactor merged, tests ≥ 85 % coverage -* [ ] `seedpass` installs and passes `python -m seedpass.cli --help` -* [ ] `seedpass-gui` binary opens vault, lists entries, adds & edits, syncs -* [ ] GitHub Actions builds binaries for Win/macOS/Linux on tag -* [ ] `docs/ARCHITECTURE.md` diagrams core ↔ CLI ↔ GUI layers - -When the above are ✅ we can ship `v2.0.0-beta.1` and invite early desktop testers. - ---- - -### 🔑 Key Takeaways - -1. **Keep all state & crypto in the core package.** -2. **Expose a clean Python API first – GUI is “just another client.”** -3. **Checksum + replaceable Nostr events give rock-solid sync & conflict handling.** -4. **Lock files and StateManager prevent index reuse and vault corruption.** -5. **The GUI sprint starts only after Phase 1 + 2 are fully green in CI.** - From f31e2663b61da7eb34d43f360388d855f69a09a2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 18 Jul 2025 22:11:35 -0400 Subject: [PATCH 38/75] docs: add briefcase packaging section --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index 9b671ff7..9b0ce21a 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - [Managing Multiple Seeds](#managing-multiple-seeds) - [Additional Entry Types](#additional-entry-types) - [Building a standalone executable](#building-a-standalone-executable) +- [Packaging with Briefcase](#packaging-with-briefcase) - [Security Considerations](#security-considerations) - [Contributing](#contributing) - [License](#license) @@ -609,6 +610,27 @@ package for your platform. The standalone executable will appear in the `dist/` directory. This process works on Windows, macOS and Linux but you must build on each platform for a native binary. +## Packaging with Briefcase + +For step-by-step instructions see [docs/docs/content/01-getting-started/05-briefcase.md](docs/docs/content/01-getting-started/05-briefcase.md). + +Install Briefcase and create a platform-specific scaffold: + +```bash +python -m pip install briefcase +briefcase create +``` + +Build and run the packaged GUI: + +```bash +briefcase build +briefcase run +``` + +You can also launch the GUI directly with `seedpass gui` or `seedpass-gui`. + + ## Security Considerations **Important:** The password you use to encrypt your parent seed is also required to decrypt the seed index data retrieved from Nostr. **It is imperative to remember this password** and be sure to use it with the same seed, as losing it means you won't be able to access your stored index. Secure your 12-word seed **and** your master password. From ea579aaa5d283d30fbf738f24ba2d320e616420e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 18 Jul 2025 22:26:18 -0400 Subject: [PATCH 39/75] GUI: support multiple entry types --- src/seedpass_gui/app.py | 59 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/src/seedpass_gui/app.py b/src/seedpass_gui/app.py index a859afea..ea6a1cb5 100644 --- a/src/seedpass_gui/app.py +++ b/src/seedpass_gui/app.py @@ -5,6 +5,7 @@ from toga.style import Pack from toga.style.pack import COLUMN, ROW from seedpass.core.manager import PasswordManager +from seedpass.core.entry_types import EntryType import time from seedpass.core.api import ( @@ -86,7 +87,8 @@ class MainWindow(toga.Window): self.last_sync = None self.table = toga.Table( - headings=["ID", "Label", "Username", "URL"], style=Pack(flex=1) + headings=["ID", "Label", "Kind", "Info 1", "Info 2"], + style=Pack(flex=1), ) add_button = toga.Button("Add", on_press=self.add_entry) @@ -113,7 +115,18 @@ class MainWindow(toga.Window): def refresh_entries(self) -> None: self.table.data = [] for idx, label, username, url, _arch in self.entries.list_entries(): - self.table.data.append((idx, label, username or "", url or "")) + entry = self.entries.retrieve_entry(idx) + kind = (entry or {}).get("kind", (entry or {}).get("type", "")) + info1 = "" + info2 = "" + if kind == EntryType.PASSWORD.value: + info1 = username or "" + info2 = url or "" + elif kind == EntryType.KEY_VALUE.value: + info1 = entry.get("value", "") if entry else "" + else: + info1 = str(entry.get("index", "")) if entry else "" + self.table.data.append((idx, label, kind, info1, info2)) # --- Button handlers ------------------------------------------------- def add_entry(self, widget: toga.Widget) -> None: @@ -164,11 +177,17 @@ class EntryDialog(toga.Window): self.entry_id = entry_id self.label_input = toga.TextInput(style=Pack(flex=1)) + self.kind_input = toga.Selection( + items=[e.value for e in EntryType], + style=Pack(flex=1), + ) + self.kind_input.value = EntryType.PASSWORD.value self.username_input = toga.TextInput(style=Pack(flex=1)) self.url_input = toga.TextInput(style=Pack(flex=1)) self.length_input = toga.NumberInput( min=8, max=128, style=Pack(width=80), value=16 ) + self.value_input = toga.TextInput(style=Pack(flex=1)) save_button = toga.Button( "Save", on_press=self.save, style=Pack(padding_top=10) @@ -177,12 +196,16 @@ class EntryDialog(toga.Window): box = toga.Box(style=Pack(direction=COLUMN, padding=20)) box.add(toga.Label("Label")) box.add(self.label_input) + box.add(toga.Label("Kind")) + box.add(self.kind_input) box.add(toga.Label("Username")) box.add(self.username_input) box.add(toga.Label("URL")) box.add(self.url_input) box.add(toga.Label("Length")) box.add(self.length_input) + box.add(toga.Label("Value")) + box.add(self.value_input) box.add(save_button) self.content = box @@ -190,22 +213,46 @@ class EntryDialog(toga.Window): entry = self.main.entries.retrieve_entry(entry_id) if entry: self.label_input.value = entry.get("label", "") + kind = entry.get("kind", entry.get("type", EntryType.PASSWORD.value)) + self.kind_input.value = kind + self.kind_input.enabled = False self.username_input.value = entry.get("username", "") or "" self.url_input.value = entry.get("url", "") or "" self.length_input.value = entry.get("length", 16) + self.value_input.value = entry.get("value", "") def save(self, widget: toga.Widget) -> None: label = self.label_input.value or "" username = self.username_input.value or None url = self.url_input.value or None length = int(self.length_input.value or 16) + kind = self.kind_input.value + value = self.value_input.value or None if self.entry_id is None: - self.main.entries.add_entry(label, length, username=username, url=url) + if kind == EntryType.PASSWORD.value: + self.main.entries.add_entry(label, length, username=username, url=url) + elif kind == EntryType.TOTP.value: + self.main.entries.add_totp(label) + elif kind == EntryType.SSH.value: + self.main.entries.add_ssh_key(label) + elif kind == EntryType.SEED.value: + self.main.entries.add_seed(label) + elif kind == EntryType.PGP.value: + self.main.entries.add_pgp_key(label) + elif kind == EntryType.NOSTR.value: + self.main.entries.add_nostr_key(label) + elif kind == EntryType.KEY_VALUE.value: + self.main.entries.add_key_value(label, value or "") + elif kind == EntryType.MANAGED_ACCOUNT.value: + self.main.entries.add_managed_account(label) else: - self.main.entries.modify_entry( - self.entry_id, username=username, url=url, label=label - ) + kwargs = {"label": label} + if kind == EntryType.PASSWORD.value: + kwargs.update({"username": username, "url": url}) + elif kind == EntryType.KEY_VALUE.value: + kwargs.update({"value": value}) + self.main.entries.modify_entry(self.entry_id, **kwargs) self.main.refresh_entries() self.close() From c2809032fd4dd802f9cc87cf00c9940a797f4a0c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 18 Jul 2025 22:41:53 -0400 Subject: [PATCH 40/75] Add TOTP viewer window --- src/seedpass_gui/app.py | 54 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/seedpass_gui/app.py b/src/seedpass_gui/app.py index ea6a1cb5..2620f99d 100644 --- a/src/seedpass_gui/app.py +++ b/src/seedpass_gui/app.py @@ -1,12 +1,15 @@ from __future__ import annotations +import asyncio +import time + import toga from toga.style import Pack from toga.style.pack import COLUMN, ROW -from seedpass.core.manager import PasswordManager from seedpass.core.entry_types import EntryType -import time +from seedpass.core.manager import PasswordManager +from seedpass.core.totp import TotpManager from seedpass.core.api import ( VaultService, @@ -95,12 +98,14 @@ class MainWindow(toga.Window): edit_button = toga.Button("Edit", on_press=self.edit_entry) search_button = toga.Button("Search", on_press=self.search_entries) relay_button = toga.Button("Relays", on_press=self.manage_relays) + totp_button = toga.Button("TOTP", on_press=self.show_totp_codes) button_box = toga.Box(style=Pack(direction=ROW, padding_top=5)) button_box.add(add_button) button_box.add(edit_button) button_box.add(search_button) button_box.add(relay_button) + button_box.add(totp_button) self.status = toga.Label("Last sync: never", style=Pack(padding_top=5)) @@ -148,6 +153,10 @@ class MainWindow(toga.Window): dlg = RelayManagerDialog(self, self.nostr) dlg.show() + def show_totp_codes(self, widget: toga.Widget) -> None: + win = TotpViewerWindow(self.controller, self.entries) + win.show() + # --- PubSub callbacks ------------------------------------------------- def sync_started(self, *args: object, **kwargs: object) -> None: self.status.text = "Syncing..." @@ -282,6 +291,47 @@ class SearchDialog(toga.Window): self.close() +class TotpViewerWindow(toga.Window): + """Window displaying active TOTP codes.""" + + def __init__(self, controller: SeedPassApp, entries: EntryService) -> None: + super().__init__("TOTP Codes", on_close=self.cleanup) + self.controller = controller + self.entries = entries + + self.table = toga.Table( + headings=["Label", "Code", "Seconds"], + style=Pack(flex=1), + ) + + box = toga.Box(style=Pack(direction=COLUMN, padding=20)) + box.add(self.table) + self.content = box + + self._running = True + self.controller.loop.create_task(self._update_loop()) + self.refresh_codes() + + async def _update_loop(self) -> None: + while self._running: + self.refresh_codes() + await asyncio.sleep(1) + + def refresh_codes(self) -> None: + self.table.data = [] + for idx, label, *_rest in self.entries.list_entries( + filter_kind=EntryType.TOTP.value + ): + entry = self.entries.retrieve_entry(idx) + code = self.entries.get_totp_code(idx) + period = int(entry.get("period", 30)) if entry else 30 + remaining = TotpManager.time_remaining(period) + self.table.data.append((label, code, remaining)) + + def cleanup(self, *args: object, **kwargs: object) -> None: + self._running = False + + class RelayManagerDialog(toga.Window): """Dialog for managing relay URLs.""" From fedd0c352a588b8508c05b5530ff8bfe3bbd6d3a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 18 Jul 2025 22:56:20 -0400 Subject: [PATCH 41/75] test: cover new gui entry types --- src/tests/test_gui_features.py | 40 +++++++++++++++- src/tests/test_gui_headless.py | 85 ++++++++++++++++++++++++++++++++-- 2 files changed, 118 insertions(+), 7 deletions(-) diff --git a/src/tests/test_gui_features.py b/src/tests/test_gui_features.py index 13769a57..7eb4b6ad 100644 --- a/src/tests/test_gui_features.py +++ b/src/tests/test_gui_features.py @@ -8,6 +8,7 @@ pytestmark = pytest.mark.desktop from seedpass.core.pubsub import bus from seedpass_gui.app import MainWindow, RelayManagerDialog +import seedpass_gui.app class DummyNostr: @@ -25,12 +26,24 @@ class DummyNostr: class DummyEntries: - def list_entries(self): - return [] + def __init__(self): + self.data = [(1, "Example", None, None, False)] + self.code = "111111" + + def list_entries(self, sort_by="index", filter_kind=None, include_archived=False): + if filter_kind: + return [(idx, label, None, None, False) for idx, label, *_ in self.data] + return self.data def search_entries(self, q): return [] + def retrieve_entry(self, idx): + return {"period": 30} + + def get_totp_code(self, idx): + return self.code + class DummyController: def __init__(self): @@ -76,3 +89,26 @@ def test_status_bar_updates_and_lock(): bus.publish("vault_locked") assert getattr(ctrl, "locked", False) assert ctrl.main_window is None + + +def test_totp_viewer_refresh_on_sync(monkeypatch): + toga.App("T3", "o3") + ctrl = DummyController() + nostr = DummyNostr() + entries = DummyEntries() + win = MainWindow(ctrl, None, entries, nostr) + ctrl.main_window = win + ctrl.loop = types.SimpleNamespace(create_task=lambda c: None) + + # prevent background loop from running + monkeypatch.setattr( + seedpass_gui.app.TotpViewerWindow, "_update_loop", lambda self: None + ) + + viewer = seedpass_gui.app.TotpViewerWindow(ctrl, entries) + bus.subscribe("sync_finished", viewer.refresh_codes) + + assert viewer.table.data[0][1] == "111111" + entries.code = "222222" + bus.publish("sync_finished") + assert viewer.table.data[0][1] == "222222" diff --git a/src/tests/test_gui_headless.py b/src/tests/test_gui_headless.py index ef5977b7..cf371e8a 100644 --- a/src/tests/test_gui_headless.py +++ b/src/tests/test_gui_headless.py @@ -2,6 +2,9 @@ import os from types import SimpleNamespace import toga +import pytest + +from seedpass.core.entry_types import EntryType from seedpass_gui.app import LockScreenWindow, MainWindow, EntryDialog @@ -26,11 +29,39 @@ class FakeEntries: return [] def add_entry(self, label, length, username=None, url=None): - self.added.append((label, length, username, url)) + self.added.append(("password", label, length, username, url)) return 1 - def modify_entry(self, entry_id, username=None, url=None, label=None): - self.modified.append((entry_id, username, url, label)) + def add_totp(self, label): + self.added.append(("totp", label)) + return 1 + + def add_ssh_key(self, label): + self.added.append(("ssh", label)) + return 1 + + def add_seed(self, label): + self.added.append(("seed", label)) + return 1 + + def add_pgp_key(self, label): + self.added.append(("pgp", label)) + return 1 + + def add_nostr_key(self, label): + self.added.append(("nostr", label)) + return 1 + + def add_key_value(self, label, value): + self.added.append(("key_value", label, value)) + return 1 + + def add_managed_account(self, label): + self.added.append(("managed_account", label)) + return 1 + + def modify_entry(self, entry_id, username=None, url=None, label=None, value=None): + self.modified.append((entry_id, username, url, label, value)) def setup_module(module): @@ -65,16 +96,60 @@ def test_unlock_creates_main_window(): controller.main_window.cleanup() -def test_entrydialog_add_calls_service(): +@pytest.mark.parametrize( + "kind,expect", + [ + (EntryType.PASSWORD.value, ("password", "L", 12, "u", "x")), + (EntryType.TOTP.value, ("totp", "L")), + (EntryType.SSH.value, ("ssh", "L")), + (EntryType.SEED.value, ("seed", "L")), + (EntryType.PGP.value, ("pgp", "L")), + (EntryType.NOSTR.value, ("nostr", "L")), + (EntryType.KEY_VALUE.value, ("key_value", "L", "val")), + (EntryType.MANAGED_ACCOUNT.value, ("managed_account", "L")), + ], +) +def test_entrydialog_add_calls_service(kind, expect): toga.App("Test2", "org.example2") entries = FakeEntries() main = SimpleNamespace(entries=entries, refresh_entries=lambda: None) dlg = EntryDialog(main, None) dlg.label_input.value = "L" + dlg.kind_input.value = kind dlg.username_input.value = "u" dlg.url_input.value = "x" dlg.length_input.value = 12 + dlg.value_input.value = "val" dlg.save(None) - assert entries.added == [("L", 12, "u", "x")] + assert entries.added[-1] == expect + + +@pytest.mark.parametrize( + "kind,expected", + [ + (EntryType.PASSWORD.value, (1, "newu", "newx", "New", None)), + (EntryType.KEY_VALUE.value, (1, None, None, "New", "val2")), + (EntryType.TOTP.value, (1, None, None, "New", None)), + ], +) +def test_entrydialog_edit_calls_service(kind, expected): + toga.App("Edit", "org.edit") + entries = FakeEntries() + + def retrieve(_id): + return {"kind": kind} + + entries.retrieve_entry = retrieve + + main = SimpleNamespace(entries=entries, refresh_entries=lambda: None) + dlg = EntryDialog(main, 1) + dlg.label_input.value = "New" + dlg.kind_input.value = kind + dlg.username_input.value = "newu" + dlg.url_input.value = "newx" + dlg.value_input.value = "val2" + dlg.save(None) + + assert entries.modified[-1] == expected From c3e2ff4b4b740177b65194762d9d67a067724ebc Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 18 Jul 2025 23:06:07 -0400 Subject: [PATCH 42/75] docs: document GUI event subscriptions --- .../01-getting-started/06-gui_adapter.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/docs/content/01-getting-started/06-gui_adapter.md b/docs/docs/content/01-getting-started/06-gui_adapter.md index 54215c7b..779fec42 100644 --- a/docs/docs/content/01-getting-started/06-gui_adapter.md +++ b/docs/docs/content/01-getting-started/06-gui_adapter.md @@ -116,3 +116,24 @@ events are published on the internal pubsub bus. When a ``vault_locked`` event is emitted, the GUI automatically returns to the lock screen so the session can be reopened with the master password. + +## Event Handling + +The GUI subscribes to a few core events so the interface reacts automatically when the vault changes state. When `MainWindow` is created it registers callbacks for `sync_started`, `sync_finished` and `vault_locked` on the global pubsub `bus`: + +```python +bus.subscribe("sync_started", self.sync_started) +bus.subscribe("sync_finished", self.sync_finished) +bus.subscribe("vault_locked", self.vault_locked) +``` + +Each handler updates the status bar or returns to the lock screen. The `cleanup` method removes these hooks when the window closes: + +```python +def cleanup(self, *args: object, **kwargs: object) -> None: + bus.unsubscribe("sync_started", self.sync_started) + bus.unsubscribe("sync_finished", self.sync_finished) + bus.unsubscribe("vault_locked", self.vault_locked) +``` + +The [TOTP window](../../02-api_reference.md#totp) demonstrates how such events keep the UI fresh: it shows live two-factor codes that reflect the latest vault data after synchronization. From c6a87e000d9d19a20dedaa3c5fe7585323a47a64 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 12:09:39 -0400 Subject: [PATCH 43/75] docs: clarify GUI backend requirement --- README.md | 16 ++++++++++++++++ .../content/01-getting-started/06-gui_adapter.md | 15 +++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/README.md b/README.md index 9b0ce21a..587c9b83 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,22 @@ python -m seedpass_gui seedpass-gui ``` +To display the interface, you must also install the Toga backend for your +platform. Only `toga-core` and the headless `toga-dummy` backend are included +in the requirements file. Depending on your operating system install one of the +following packages: + +```bash +# Linux +pip install toga-gtk + +# Windows +pip install toga-winforms + +# macOS +pip install toga-cocoa +``` + The GUI works with the same vault and configuration files as the CLI. ```mermaid diff --git a/docs/docs/content/01-getting-started/06-gui_adapter.md b/docs/docs/content/01-getting-started/06-gui_adapter.md index 779fec42..b0531b98 100644 --- a/docs/docs/content/01-getting-started/06-gui_adapter.md +++ b/docs/docs/content/01-getting-started/06-gui_adapter.md @@ -14,6 +14,21 @@ python -m seedpass_gui seedpass-gui ``` +Only the headless `toga-dummy` backend ships with the project for automated +tests. To actually display windows you need a platform-specific backend such as +`toga-gtk`, `toga-winforms`, or `toga-cocoa`. + +```bash +# Linux +pip install toga-gtk + +# Windows +pip install toga-winforms + +# macOS +pip install toga-cocoa +``` + The GUI shares the same encrypted vault and configuration as the command line tool. To generate a packaged binary, run `briefcase build` (after the initial `briefcase create`). From 70e05970f098b448cea7a675551087903f1c7fcf Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 12:16:30 -0400 Subject: [PATCH 44/75] Document GUI backend requirement and handle missing backend --- README.md | 9 +++++++++ .../01-getting-started/01-advanced_cli.md | 4 ++++ .../01-getting-started/06-gui_adapter.md | 5 +++++ docs/docs/content/index.md | 4 ++++ src/seedpass/cli.py | 17 +++++++++++++++++ src/tests/test_typer_cli.py | 8 ++++++++ 6 files changed, 47 insertions(+) diff --git a/README.md b/README.md index 9b0ce21a..448832e3 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,11 @@ python -m seedpass_gui seedpass-gui ``` +Install a platform-specific BeeWare backend before running these commands. Only +the headless `toga-dummy` backend is bundled for tests. Linux users should +install `toga-gtk`, Windows users need `toga-winforms`, and macOS users require +`toga-cocoa`. + The GUI works with the same vault and configuration files as the CLI. ```mermaid @@ -555,6 +560,10 @@ If the checksum file is missing, generate it manually: python scripts/update_checksum.py ``` +If SeedPass prints a "script checksum mismatch" warning on startup, regenerate +the checksum with `seedpass util update-checksum` or select "Generate Script +Checksum" from the Settings menu. + To run mutation tests locally, generate coverage data first and then execute `mutmut`: ```bash diff --git a/docs/docs/content/01-getting-started/01-advanced_cli.md b/docs/docs/content/01-getting-started/01-advanced_cli.md index 1c0bed62..3c86dcc5 100644 --- a/docs/docs/content/01-getting-started/01-advanced_cli.md +++ b/docs/docs/content/01-getting-started/01-advanced_cli.md @@ -116,6 +116,10 @@ Miscellaneous helper commands. | Verify script checksum | `util verify-checksum` | `seedpass util verify-checksum` | | Update script checksum | `util update-checksum` | `seedpass util update-checksum` | +If you see a startup warning about a script checksum mismatch, +run `seedpass util update-checksum` or choose "Generate Script Checksum" +from the Settings menu to update the stored value. + ### API Commands Run or stop the local HTTP API. diff --git a/docs/docs/content/01-getting-started/06-gui_adapter.md b/docs/docs/content/01-getting-started/06-gui_adapter.md index 779fec42..e2c4917f 100644 --- a/docs/docs/content/01-getting-started/06-gui_adapter.md +++ b/docs/docs/content/01-getting-started/06-gui_adapter.md @@ -14,6 +14,11 @@ python -m seedpass_gui seedpass-gui ``` +Install a platform-specific BeeWare backend before launching the GUI. The +distribution only bundles the headless `toga-dummy` backend for automated +tests. Linux users should install `toga-gtk`, Windows users need +`toga-winforms`, and macOS users require `toga-cocoa`. + The GUI shares the same encrypted vault and configuration as the command line tool. To generate a packaged binary, run `briefcase build` (after the initial `briefcase create`). diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index 35b069dd..d2722063 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -497,6 +497,10 @@ If the checksum file is missing, generate it manually: python scripts/update_checksum.py ``` +If SeedPass reports a "script checksum mismatch" warning on startup, +regenerate the checksum with `seedpass util update-checksum` or select +"Generate Script Checksum" from the Settings menu. + To run mutation tests locally, generate coverage data first and then execute `mutmut`: ```bash diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index df7f56c0..bddc7ee0 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -24,6 +24,7 @@ import uvicorn from . import api as api_module import importlib +import importlib.util app = typer.Typer( help="SeedPass command line interface", @@ -95,6 +96,14 @@ def _get_nostr_service(ctx: typer.Context) -> NostrService: return NostrService(pm) +def _gui_backend_available() -> bool: + """Return True if a platform-specific BeeWare backend is installed.""" + for pkg in ("toga_gtk", "toga_winforms", "toga_cocoa"): + if importlib.util.find_spec(pkg) is not None: + return True + return False + + @app.callback(invoke_without_command=True) def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None: """SeedPass CLI entry point. @@ -740,6 +749,14 @@ def api_stop(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> N @app.command() def gui() -> None: """Launch the BeeWare GUI.""" + if not _gui_backend_available(): + typer.echo( + "No BeeWare GUI backend found. Install 'toga-gtk' (Linux), " + "'toga-winforms' (Windows) or 'toga-cocoa' (macOS) to run the GUI.", + err=True, + ) + raise typer.Exit(1) + from seedpass_gui.app import main main() diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index c2db4dac..fc48c82c 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -557,6 +557,14 @@ def test_gui_command(monkeypatch): "seedpass_gui.app", SimpleNamespace(main=fake_main), ) + monkeypatch.setattr(cli.importlib.util, "find_spec", lambda n: True) result = runner.invoke(app, ["gui"]) assert result.exit_code == 0 assert called.get("called") is True + + +def test_gui_command_no_backend(monkeypatch): + monkeypatch.setattr(cli.importlib.util, "find_spec", lambda n: None) + result = runner.invoke(app, ["gui"]) + assert result.exit_code == 1 + assert "BeeWare GUI backend" in result.stderr From ad3f1bc80c3b3cbfcd6ea813b93e28d897ca83c1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 13:00:35 -0400 Subject: [PATCH 45/75] Add platform-specific Toga installation --- scripts/install.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/install.sh b/scripts/install.sh index cb070992..1fa2de1f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -120,6 +120,14 @@ main() { pip install --upgrade pip pip install -r src/requirements.txt pip install -e . + print_info "Installing platform-specific Toga backend..." + if [ "$OS_NAME" = "Linux" ]; then + print_info "Installing toga-gtk for Linux..." + pip install toga-gtk + elif [ "$OS_NAME" = "Darwin" ]; then + print_info "Installing toga-cocoa for macOS..." + pip install toga-cocoa + fi deactivate # 7. Create launcher script From d7df6679bd726fc39a5c82b78ba9c0847eecf467 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 13:09:31 -0400 Subject: [PATCH 46/75] Install toga-winforms backend on Windows --- scripts/install.ps1 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d843b8b4..fcb09faa 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -260,6 +260,10 @@ if ($LASTEXITCODE -ne 0) { Write-Error "Failed to install SeedPass package" } +Write-Info "Installing BeeWare GUI backend..." +& "$VenvDir\Scripts\python.exe" -m pip install toga-winforms +if ($LASTEXITCODE -ne 0) { Write-Warning "Failed to install GUI backend" } + # 5. Create launcher script Write-Info "Creating launcher script..." if (-not (Test-Path $LauncherDir)) { New-Item -ItemType Directory -Path $LauncherDir | Out-Null } From bfe65c97077aa487919a6a89e166cafc4a575cb9 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 13:19:20 -0400 Subject: [PATCH 47/75] docs: mention automatic BeeWare backend install --- README.md | 9 +++++---- docs/docs/content/01-getting-started/06-gui_adapter.md | 7 ++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4a24aab2..f80cf8a4 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ See `docs/ARCHITECTURE.md` for details. ### Quick Installer Use the automated installer to download SeedPass and its dependencies in one step. +The scripts also install the correct BeeWare backend for your platform automatically. **Linux and macOS:** ```bash @@ -241,10 +242,10 @@ python -m seedpass_gui seedpass-gui ``` -To display the interface, you must also install the Toga backend for your -platform. Only `toga-core` and the headless `toga-dummy` backend are included -in the requirements file. Depending on your operating system install one of the -following packages: +Only `toga-core` and the headless `toga-dummy` backend are included by default. +The quick installer automatically installs the correct BeeWare backend so the +GUI works out of the box. If you set up SeedPass manually, install the backend +for your platform: ```bash # Linux diff --git a/docs/docs/content/01-getting-started/06-gui_adapter.md b/docs/docs/content/01-getting-started/06-gui_adapter.md index b0531b98..fe77b861 100644 --- a/docs/docs/content/01-getting-started/06-gui_adapter.md +++ b/docs/docs/content/01-getting-started/06-gui_adapter.md @@ -14,9 +14,10 @@ python -m seedpass_gui seedpass-gui ``` -Only the headless `toga-dummy` backend ships with the project for automated -tests. To actually display windows you need a platform-specific backend such as -`toga-gtk`, `toga-winforms`, or `toga-cocoa`. +Only `toga-core` and the headless `toga-dummy` backend ship with the project. +The installation scripts automatically install the correct BeeWare backend so +the GUI works out of the box. If you set up SeedPass manually, install the +backend for your platform: ```bash # Linux From 04862bce4834c3935438f44c03f4d9f4c4eee73f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 13:43:49 -0400 Subject: [PATCH 48/75] install: ensure cairo installed --- README.md | 4 ++++ .../content/01-getting-started/06-gui_adapter.md | 3 +++ scripts/install.sh | 15 ++++++++++----- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f80cf8a4..2e0983b4 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,10 @@ for your platform: # Linux pip install toga-gtk +# If you see build errors about "cairo" on Linux, install the cairo +# development headers using your package manager, e.g.: +sudo apt-get install libcairo2 libcairo2-dev + # Windows pip install toga-winforms diff --git a/docs/docs/content/01-getting-started/06-gui_adapter.md b/docs/docs/content/01-getting-started/06-gui_adapter.md index fe77b861..dcb2dac0 100644 --- a/docs/docs/content/01-getting-started/06-gui_adapter.md +++ b/docs/docs/content/01-getting-started/06-gui_adapter.md @@ -23,6 +23,9 @@ backend for your platform: # Linux pip install toga-gtk +# If installation fails with cairo errors, install libcairo2-dev or the +# cairo development package using your distro's package manager. + # Windows pip install toga-winforms diff --git a/scripts/install.sh b/scripts/install.sh index 1fa2de1f..95bc6a4f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -86,13 +86,18 @@ main() { # 3. Install OS-specific dependencies print_info "Checking for build dependencies..." if [ "$OS_NAME" = "Linux" ]; then - if command -v apt-get &> /dev/null; then sudo apt-get update && sudo apt-get install -y build-essential pkg-config xclip; - elif command -v dnf &> /dev/null; then sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y pkg-config xclip; - elif command -v pacman &> /dev/null; then sudo pacman -Syu --noconfirm base-devel pkg-config xclip; - else print_warning "Could not detect package manager. Ensure build tools and pkg-config are installed."; fi + if command -v apt-get &> /dev/null; then + sudo apt-get update && sudo apt-get install -y build-essential pkg-config xclip libcairo2 libcairo2-dev + elif command -v dnf &> /dev/null; then + sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y pkg-config cairo cairo-devel xclip + elif command -v pacman &> /dev/null; then + sudo pacman -Syu --noconfirm base-devel pkg-config cairo xclip + else + print_warning "Could not detect package manager. Ensure build tools, cairo, and pkg-config are installed." + fi elif [ "$OS_NAME" = "Darwin" ]; then if ! command -v brew &> /dev/null; then print_error "Homebrew not installed. See https://brew.sh/"; fi - brew install pkg-config + brew install pkg-config cairo fi # 4. Clone or update the repository From 64c174c385e318ea6fc3fbb7ed80b6dfb03146c7 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 13:53:02 -0400 Subject: [PATCH 49/75] Fix CLI modify error on mac --- src/seedpass/cli.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index bddc7ee0..3fceb5aa 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -3,6 +3,7 @@ from typing import Optional, List import json import typer +import sys from seedpass.core.manager import PasswordManager from seedpass.core.entry_types import EntryType @@ -368,6 +369,7 @@ def entry_modify( ) except ValueError as e: typer.echo(str(e)) + sys.stdout.flush() raise typer.Exit(code=1) From a01e0f0037ba3942b112e9c4d628b87e174bb907 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 14:11:34 -0400 Subject: [PATCH 50/75] fix: install missing deps for pygobject --- scripts/install.sh | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 95bc6a4f..cf8155e0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -87,11 +87,18 @@ main() { print_info "Checking for build dependencies..." if [ "$OS_NAME" = "Linux" ]; then if command -v apt-get &> /dev/null; then - sudo apt-get update && sudo apt-get install -y build-essential pkg-config xclip libcairo2 libcairo2-dev + sudo apt-get update && sudo apt-get install -y \ + build-essential pkg-config xclip \ + libcairo2 libcairo2-dev \ + libgirepository-1.0-dev gobject-introspection \ + gir1.2-gtk-3.0 python3-dev elif command -v dnf &> /dev/null; then - sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y pkg-config cairo cairo-devel xclip + sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y \ + pkg-config cairo cairo-devel xclip \ + gobject-introspection-devel cairo-devel gtk3-devel python3-devel elif command -v pacman &> /dev/null; then - sudo pacman -Syu --noconfirm base-devel pkg-config cairo xclip + sudo pacman -Syu --noconfirm base-devel pkg-config cairo xclip \ + gobject-introspection cairo gtk3 python else print_warning "Could not detect package manager. Ensure build tools, cairo, and pkg-config are installed." fi From c80495eca66491f08bc187223405b547c8b92515 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 14:21:24 -0400 Subject: [PATCH 51/75] fix linux dependencies --- scripts/install.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/install.sh b/scripts/install.sh index cf8155e0..d6973262 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -90,7 +90,8 @@ main() { sudo apt-get update && sudo apt-get install -y \ build-essential pkg-config xclip \ libcairo2 libcairo2-dev \ - libgirepository-1.0-dev gobject-introspection \ + libgirepository-2.0-dev gir1.2-girepository-2.0 \ + gobject-introspection \ gir1.2-gtk-3.0 python3-dev elif command -v dnf &> /dev/null; then sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y \ From 9fc117b105c67aae4c580dcc5d5c622ce878fef1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 14:46:16 -0400 Subject: [PATCH 52/75] feat(cli): auto install GUI backend --- src/seedpass/cli.py | 32 +++++++++++++++++++++++++++++--- src/tests/test_typer_cli.py | 34 +++++++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 3fceb5aa..7cc3b20d 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -26,6 +26,7 @@ from . import api as api_module import importlib import importlib.util +import subprocess app = typer.Typer( help="SeedPass command line interface", @@ -750,11 +751,36 @@ def api_stop(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> N @app.command() def gui() -> None: - """Launch the BeeWare GUI.""" + """Launch the BeeWare GUI. + + If the platform specific backend is missing, attempt to install it and + retry launching the GUI. + """ + if not _gui_backend_available(): + if sys.platform.startswith("linux"): + pkg = "toga-gtk" + elif sys.platform == "win32": + pkg = "toga-winforms" + elif sys.platform == "darwin": + pkg = "toga-cocoa" + else: + typer.echo( + f"Unsupported platform '{sys.platform}' for BeeWare GUI.", + err=True, + ) + raise typer.Exit(1) + + typer.echo(f"Attempting to install {pkg} for GUI support...") + try: + subprocess.check_call([sys.executable, "-m", "pip", "install", pkg]) + typer.echo(f"Successfully installed {pkg}.") + except subprocess.CalledProcessError as exc: + typer.echo(f"Failed to install {pkg}: {exc}", err=True) + raise typer.Exit(1) + if not _gui_backend_available(): typer.echo( - "No BeeWare GUI backend found. Install 'toga-gtk' (Linux), " - "'toga-winforms' (Windows) or 'toga-cocoa' (macOS) to run the GUI.", + "BeeWare GUI backend still unavailable after installation attempt.", err=True, ) raise typer.Exit(1) diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index fc48c82c..35b3d0d6 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -564,7 +564,35 @@ def test_gui_command(monkeypatch): def test_gui_command_no_backend(monkeypatch): - monkeypatch.setattr(cli.importlib.util, "find_spec", lambda n: None) + """Install backend if missing and launch GUI.""" + + call_count = {"n": 0} + + def backend_available() -> bool: + call_count["n"] += 1 + return call_count["n"] > 1 + + monkeypatch.setattr(cli, "_gui_backend_available", backend_available) + + installed = {} + + def fake_check_call(cmd): + installed["cmd"] = cmd + + monkeypatch.setattr(cli.subprocess, "check_call", fake_check_call) + + called = {} + + def fake_main(): + called["gui"] = True + + monkeypatch.setitem( + sys.modules, + "seedpass_gui.app", + SimpleNamespace(main=fake_main), + ) + result = runner.invoke(app, ["gui"]) - assert result.exit_code == 1 - assert "BeeWare GUI backend" in result.stderr + assert result.exit_code == 0 + assert installed.get("cmd") is not None + assert called.get("gui") is True From bdf6a038c2f559711737f615c0623a32b22e3824 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 15:17:16 -0400 Subject: [PATCH 53/75] Enhance install scripts to warn about stale executables --- scripts/install.ps1 | 12 ++++++++++++ scripts/install.sh | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index fcb09faa..08838994 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -283,6 +283,18 @@ if ($existingSeedpass -and $existingSeedpass.Source -ne $LauncherPath) { Write-Warning "Ensure '$LauncherDir' comes first in your PATH or remove the old installation." } +# Detect additional seedpass executables on PATH that are not our launcher +$allSeedpass = Get-Command seedpass -All -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source +$stale = @() +foreach ($cmd in $allSeedpass) { + if ($cmd -ne $LauncherPath) { $stale += $cmd } +} +if ($stale.Count -gt 0) { + Write-Warning "Stale 'seedpass' executables detected:" + foreach ($cmd in $stale) { Write-Warning " - $cmd" } + Write-Warning "Remove or rename these to avoid launching outdated code." +} + # 6. Add launcher directory to User's PATH if needed Write-Info "Checking if '$LauncherDir' is in your PATH..." $UserPath = [System.Environment]::GetEnvironmentVariable("Path", "User") diff --git a/scripts/install.sh b/scripts/install.sh index d6973262..f6df808a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -159,6 +159,23 @@ EOF2 print_warning "Ensure '$LAUNCHER_DIR' comes first in your PATH or remove the old installation." fi + # Detect any additional seedpass executables on PATH that are not our launcher + IFS=':' read -ra _sp_paths <<< "$PATH" + stale_cmds=() + for _dir in "${_sp_paths[@]}"; do + _candidate="$_dir/seedpass" + if [ -x "$_candidate" ] && [ "$_candidate" != "$LAUNCHER_PATH" ]; then + stale_cmds+=("$_candidate") + fi + done + if [ ${#stale_cmds[@]} -gt 0 ]; then + print_warning "Stale 'seedpass' executables detected:" + for cmd in "${stale_cmds[@]}"; do + print_warning " - $cmd" + done + print_warning "Remove or rename these to avoid launching outdated code." + fi + # 8. Final instructions print_success "Installation/update complete!" print_info "You can now launch the interactive TUI by typing: seedpass" From ff1f8bb4e1b6b97d43f4d6f0b5c2677a5fcb4f0b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 15:50:52 -0400 Subject: [PATCH 54/75] Use background sync in entry service --- src/seedpass/core/api.py | 22 +++++++++---------- src/seedpass_gui/app.py | 27 ++++++++++++++++++++---- src/tests/test_cli_core_services.py | 5 +++-- src/tests/test_cli_doc_examples.py | 1 + src/tests/test_cli_entry_add_commands.py | 4 ++-- src/tests/test_core_services.py | 4 ++-- src/tests/test_typer_cli.py | 10 ++++----- 7 files changed, 47 insertions(+), 26 deletions(-) diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py index d51a21e0..03e8d99c 100644 --- a/src/seedpass/core/api.py +++ b/src/seedpass/core/api.py @@ -282,7 +282,7 @@ class EntryService: ) -> int: with self._lock: idx = self._manager.entry_manager.add_entry(label, length, username, url) - self._manager.sync_vault() + self._manager.start_background_vault_sync() return idx def add_totp( @@ -303,7 +303,7 @@ class EntryService: period=period, digits=digits, ) - self._manager.sync_vault() + self._manager.start_background_vault_sync() return uri def add_ssh_key( @@ -320,7 +320,7 @@ class EntryService: index=index, notes=notes, ) - self._manager.sync_vault() + self._manager.start_background_vault_sync() return idx def add_pgp_key( @@ -341,7 +341,7 @@ class EntryService: user_id=user_id, notes=notes, ) - self._manager.sync_vault() + self._manager.start_background_vault_sync() return idx def add_nostr_key( @@ -357,7 +357,7 @@ class EntryService: index=index, notes=notes, ) - self._manager.sync_vault() + self._manager.start_background_vault_sync() return idx def add_seed( @@ -376,13 +376,13 @@ class EntryService: words_num=words, notes=notes, ) - self._manager.sync_vault() + self._manager.start_background_vault_sync() return idx def add_key_value(self, label: str, value: str, *, notes: str = "") -> int: with self._lock: idx = self._manager.entry_manager.add_key_value(label, value, notes=notes) - self._manager.sync_vault() + self._manager.start_background_vault_sync() return idx def add_managed_account( @@ -399,7 +399,7 @@ class EntryService: index=index, notes=notes, ) - self._manager.sync_vault() + self._manager.start_background_vault_sync() return idx def modify_entry( @@ -425,17 +425,17 @@ class EntryService: digits=digits, value=value, ) - self._manager.sync_vault() + self._manager.start_background_vault_sync() def archive_entry(self, entry_id: int) -> None: with self._lock: self._manager.entry_manager.archive_entry(entry_id) - self._manager.sync_vault() + self._manager.start_background_vault_sync() def restore_entry(self, entry_id: int) -> None: with self._lock: self._manager.entry_manager.restore_entry(entry_id) - self._manager.sync_vault() + self._manager.start_background_vault_sync() def export_totp_entries(self) -> dict: with self._lock: diff --git a/src/seedpass_gui/app.py b/src/seedpass_gui/app.py index 2620f99d..dcc38ffb 100644 --- a/src/seedpass_gui/app.py +++ b/src/seedpass_gui/app.py @@ -5,6 +5,7 @@ import time import toga from toga.style import Pack +from toga.sources import ListSource from toga.style.pack import COLUMN, ROW from seedpass.core.entry_types import EntryType @@ -89,8 +90,10 @@ class MainWindow(toga.Window): bus.subscribe("vault_locked", self.vault_locked) self.last_sync = None + self.entry_source = ListSource(["id", "label", "kind", "info1", "info2"]) self.table = toga.Table( headings=["ID", "Label", "Kind", "Info 1", "Info 2"], + data=self.entry_source, style=Pack(flex=1), ) @@ -118,7 +121,7 @@ class MainWindow(toga.Window): self.refresh_entries() def refresh_entries(self) -> None: - self.table.data = [] + self.entry_source.clear() for idx, label, username, url, _arch in self.entries.list_entries(): entry = self.entries.retrieve_entry(idx) kind = (entry or {}).get("kind", (entry or {}).get("type", "")) @@ -131,7 +134,15 @@ class MainWindow(toga.Window): info1 = entry.get("value", "") if entry else "" else: info1 = str(entry.get("index", "")) if entry else "" - self.table.data.append((idx, label, kind, info1, info2)) + self.entry_source.append( + { + "id": idx, + "label": label, + "kind": kind, + "info1": info1, + "info2": info2, + } + ) # --- Button handlers ------------------------------------------------- def add_entry(self, widget: toga.Widget) -> None: @@ -285,9 +296,17 @@ class SearchDialog(toga.Window): def do_search(self, widget: toga.Widget) -> None: query = self.query_input.value or "" results = self.main.entries.search_entries(query) - self.main.table.data = [] + self.main.entry_source.clear() for idx, label, username, url, _arch in results: - self.main.table.data.append((idx, label, username or "", url or "")) + self.main.entry_source.append( + { + "id": idx, + "label": label, + "kind": "", + "info1": username or "", + "info2": url or "", + } + ) self.close() diff --git a/src/tests/test_cli_core_services.py b/src/tests/test_cli_core_services.py index fd68b9a6..1c79a429 100644 --- a/src/tests/test_cli_core_services.py +++ b/src/tests/test_cli_core_services.py @@ -36,7 +36,7 @@ def test_cli_entry_add_search_sync(monkeypatch): calls["search"] = (q, kinds) return [(1, "Label", None, None, False)] - def sync_vault(): + def start_background_vault_sync(): calls["sync"] = True return {"manifest_id": "m", "chunk_ids": [], "delta_ids": []} @@ -44,7 +44,8 @@ def test_cli_entry_add_search_sync(monkeypatch): entry_manager=SimpleNamespace( add_entry=add_entry, search_entries=search_entries ), - sync_vault=sync_vault, + start_background_vault_sync=start_background_vault_sync, + sync_vault=lambda: {"manifest_id": "m", "chunk_ids": [], "delta_ids": []}, select_fingerprint=lambda fp: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py index 1518261c..ac5ebb18 100644 --- a/src/tests/test_cli_doc_examples.py +++ b/src/tests/test_cli_doc_examples.py @@ -58,6 +58,7 @@ class DummyPM: "chunk_ids": ["c1"], "delta_ids": [], } + self.start_background_vault_sync = lambda *a, **k: self.sync_vault() self.config_manager = SimpleNamespace( load_config=lambda require_pin=False: {"inactivity_timeout": 30}, set_inactivity_timeout=lambda v: None, diff --git a/src/tests/test_cli_entry_add_commands.py b/src/tests/test_cli_entry_add_commands.py index 5fbeafd5..dd482b3f 100644 --- a/src/tests/test_cli_entry_add_commands.py +++ b/src/tests/test_cli_entry_add_commands.py @@ -115,14 +115,14 @@ def test_entry_add_commands( called["kwargs"] = kwargs return stdout - def sync_vault(): + def start_background_vault_sync(): called["sync"] = True pm = SimpleNamespace( entry_manager=SimpleNamespace(**{method: func}), parent_seed="seed", select_fingerprint=lambda fp: None, - sync_vault=sync_vault, + start_background_vault_sync=start_background_vault_sync, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", command] + cli_args) diff --git a/src/tests/test_core_services.py b/src/tests/test_core_services.py index dc419c41..ea48a8a6 100644 --- a/src/tests/test_core_services.py +++ b/src/tests/test_core_services.py @@ -29,7 +29,7 @@ def test_entry_service_add_entry_and_search(): called["search"] = (q, kinds) return [(5, "Example", username, url, False)] - def sync_vault(): + def start_background_vault_sync(): called["sync"] = True username = "user" @@ -38,7 +38,7 @@ def test_entry_service_add_entry_and_search(): entry_manager=SimpleNamespace( add_entry=add_entry, search_entries=search_entries ), - sync_vault=sync_vault, + start_background_vault_sync=start_background_vault_sync, ) service = EntryService(pm) idx = service.add_entry("Example", 12, username, url) diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index 35b3d0d6..d49803c2 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -377,7 +377,7 @@ def test_entry_add(monkeypatch): pm = SimpleNamespace( entry_manager=SimpleNamespace(add_entry=add_entry), select_fingerprint=lambda fp: None, - sync_vault=lambda: None, + start_background_vault_sync=lambda: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke( @@ -408,7 +408,7 @@ def test_entry_modify(monkeypatch): pm = SimpleNamespace( entry_manager=SimpleNamespace(modify_entry=modify_entry), select_fingerprint=lambda fp: None, - sync_vault=lambda: None, + start_background_vault_sync=lambda: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "modify", "1", "--username", "alice"]) @@ -423,7 +423,7 @@ def test_entry_modify_invalid(monkeypatch): pm = SimpleNamespace( entry_manager=SimpleNamespace(modify_entry=modify_entry), select_fingerprint=lambda fp: None, - sync_vault=lambda: None, + start_background_vault_sync=lambda: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "modify", "1", "--username", "alice"]) @@ -440,7 +440,7 @@ def test_entry_archive(monkeypatch): pm = SimpleNamespace( entry_manager=SimpleNamespace(archive_entry=archive_entry), select_fingerprint=lambda fp: None, - sync_vault=lambda: None, + start_background_vault_sync=lambda: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "archive", "3"]) @@ -458,7 +458,7 @@ def test_entry_unarchive(monkeypatch): pm = SimpleNamespace( entry_manager=SimpleNamespace(restore_entry=restore_entry), select_fingerprint=lambda fp: None, - sync_vault=lambda: None, + start_background_vault_sync=lambda: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "unarchive", "4"]) From e6ca36b8b779739dfb118bf752e8092550f0d8d8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 17:18:46 -0400 Subject: [PATCH 55/75] Update EntryDialog to modify entry source --- src/seedpass_gui/app.py | 52 +++++++++++++++++++++++++++------- src/tests/test_gui_headless.py | 15 ++++++++-- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/seedpass_gui/app.py b/src/seedpass_gui/app.py index dcc38ffb..88ea9097 100644 --- a/src/seedpass_gui/app.py +++ b/src/seedpass_gui/app.py @@ -251,29 +251,61 @@ class EntryDialog(toga.Window): if self.entry_id is None: if kind == EntryType.PASSWORD.value: - self.main.entries.add_entry(label, length, username=username, url=url) + entry_id = self.main.entries.add_entry( + label, length, username=username, url=url + ) elif kind == EntryType.TOTP.value: - self.main.entries.add_totp(label) + entry_id = self.main.entries.add_totp(label) elif kind == EntryType.SSH.value: - self.main.entries.add_ssh_key(label) + entry_id = self.main.entries.add_ssh_key(label) elif kind == EntryType.SEED.value: - self.main.entries.add_seed(label) + entry_id = self.main.entries.add_seed(label) elif kind == EntryType.PGP.value: - self.main.entries.add_pgp_key(label) + entry_id = self.main.entries.add_pgp_key(label) elif kind == EntryType.NOSTR.value: - self.main.entries.add_nostr_key(label) + entry_id = self.main.entries.add_nostr_key(label) elif kind == EntryType.KEY_VALUE.value: - self.main.entries.add_key_value(label, value or "") + entry_id = self.main.entries.add_key_value(label, value or "") elif kind == EntryType.MANAGED_ACCOUNT.value: - self.main.entries.add_managed_account(label) + entry_id = self.main.entries.add_managed_account(label) else: + entry_id = self.entry_id kwargs = {"label": label} if kind == EntryType.PASSWORD.value: kwargs.update({"username": username, "url": url}) elif kind == EntryType.KEY_VALUE.value: kwargs.update({"value": value}) - self.main.entries.modify_entry(self.entry_id, **kwargs) - self.main.refresh_entries() + self.main.entries.modify_entry(entry_id, **kwargs) + + entry = self.main.entries.retrieve_entry(entry_id) or {} + kind = entry.get("kind", entry.get("type", kind)) + info1 = "" + info2 = "" + if kind == EntryType.PASSWORD.value: + info1 = username or "" + info2 = url or "" + elif kind == EntryType.KEY_VALUE.value: + info1 = entry.get("value", value or "") + else: + info1 = str(entry.get("index", "")) + + row = { + "id": entry_id, + "label": label, + "kind": kind, + "info1": info1, + "info2": info2, + } + + if self.entry_id is None: + self.main.entry_source.append(row) + else: + for existing in self.main.entry_source: + if getattr(existing, "id", None) == entry_id: + for key, value in row.items(): + setattr(existing, key, value) + break + self.close() diff --git a/src/tests/test_gui_headless.py b/src/tests/test_gui_headless.py index cf371e8a..dc895a84 100644 --- a/src/tests/test_gui_headless.py +++ b/src/tests/test_gui_headless.py @@ -1,5 +1,6 @@ import os from types import SimpleNamespace +from toga.sources import ListSource import toga import pytest @@ -112,7 +113,9 @@ def test_unlock_creates_main_window(): def test_entrydialog_add_calls_service(kind, expect): toga.App("Test2", "org.example2") entries = FakeEntries() - main = SimpleNamespace(entries=entries, refresh_entries=lambda: None) + entries.retrieve_entry = lambda _id: {"kind": kind} + source = ListSource(["id", "label", "kind", "info1", "info2"]) + main = SimpleNamespace(entries=entries, entry_source=source) dlg = EntryDialog(main, None) dlg.label_input.value = "L" @@ -124,6 +127,10 @@ def test_entrydialog_add_calls_service(kind, expect): dlg.save(None) assert entries.added[-1] == expect + assert len(main.entry_source) == 1 + row = main.entry_source[0] + assert row.label == "L" + assert row.kind == kind @pytest.mark.parametrize( @@ -142,8 +149,9 @@ def test_entrydialog_edit_calls_service(kind, expected): return {"kind": kind} entries.retrieve_entry = retrieve - - main = SimpleNamespace(entries=entries, refresh_entries=lambda: None) + source = ListSource(["id", "label", "kind", "info1", "info2"]) + source.append({"id": 1, "label": "Old", "kind": kind, "info1": "", "info2": ""}) + main = SimpleNamespace(entries=entries, entry_source=source) dlg = EntryDialog(main, 1) dlg.label_input.value = "New" dlg.kind_input.value = kind @@ -153,3 +161,4 @@ def test_entrydialog_edit_calls_service(kind, expected): dlg.save(None) assert entries.modified[-1] == expected + assert source[0].label == "New" From ec52d2eda0743546cbb3f07f157419b002a0b306 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 18:14:47 -0400 Subject: [PATCH 56/75] Add vault sync trigger to GUI --- src/seedpass_gui/app.py | 12 ++++++ src/tests/test_gui_sync.py | 75 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 src/tests/test_gui_sync.py diff --git a/src/seedpass_gui/app.py b/src/seedpass_gui/app.py index 88ea9097..b3041f54 100644 --- a/src/seedpass_gui/app.py +++ b/src/seedpass_gui/app.py @@ -102,6 +102,7 @@ class MainWindow(toga.Window): search_button = toga.Button("Search", on_press=self.search_entries) relay_button = toga.Button("Relays", on_press=self.manage_relays) totp_button = toga.Button("TOTP", on_press=self.show_totp_codes) + sync_button = toga.Button("Sync", on_press=self.start_vault_sync) button_box = toga.Box(style=Pack(direction=ROW, padding_top=5)) button_box.add(add_button) @@ -109,6 +110,7 @@ class MainWindow(toga.Window): button_box.add(search_button) button_box.add(relay_button) button_box.add(totp_button) + button_box.add(sync_button) self.status = toga.Label("Last sync: never", style=Pack(padding_top=5)) @@ -168,6 +170,14 @@ class MainWindow(toga.Window): win = TotpViewerWindow(self.controller, self.entries) win.show() + def start_vault_sync(self, widget: toga.Widget | None = None) -> None: + """Schedule a background vault synchronization.""" + + async def _runner() -> None: + self.nostr.start_background_vault_sync() + + self.controller.loop.create_task(_runner()) + # --- PubSub callbacks ------------------------------------------------- def sync_started(self, *args: object, **kwargs: object) -> None: self.status.text = "Syncing..." @@ -307,6 +317,8 @@ class EntryDialog(toga.Window): break self.close() + # schedule vault sync after saving + getattr(self.main, "start_vault_sync", lambda *_: None)() class SearchDialog(toga.Window): diff --git a/src/tests/test_gui_sync.py b/src/tests/test_gui_sync.py new file mode 100644 index 00000000..c17d66aa --- /dev/null +++ b/src/tests/test_gui_sync.py @@ -0,0 +1,75 @@ +import os +import types +import asyncio +import toga +import pytest + +from seedpass.core.pubsub import bus +from seedpass_gui.app import MainWindow + + +class DummyEntries: + def list_entries(self, sort_by="index", filter_kind=None, include_archived=False): + return [] + + def search_entries(self, q): + return [] + + +class DummyNostr: + def __init__(self): + self.called = False + + def start_background_vault_sync(self): + self.called = True + + def list_relays(self): + return [] + + +class DummyController: + def __init__(self, loop): + self.loop = loop + self.lock_window = types.SimpleNamespace(show=lambda: None) + self.main_window = None + self.vault_service = None + self.entry_service = None + self.nostr_service = None + + +@pytest.fixture(autouse=True) +def set_backend(): + os.environ["TOGA_BACKEND"] = "toga_dummy" + asyncio.set_event_loop(asyncio.new_event_loop()) + + +def test_start_vault_sync_schedules_task(): + toga.App("T", "o") + + tasks = [] + + def create_task(coro): + tasks.append(coro) + + loop = types.SimpleNamespace(create_task=create_task) + ctrl = DummyController(loop) + nostr = DummyNostr() + win = MainWindow(ctrl, None, DummyEntries(), nostr) + + win.start_vault_sync() + assert tasks + asyncio.get_event_loop().run_until_complete(tasks[0]) + assert nostr.called + + +def test_status_updates_on_bus_events(): + toga.App("T2", "o2") + loop = types.SimpleNamespace(create_task=lambda c: None) + ctrl = DummyController(loop) + nostr = DummyNostr() + win = MainWindow(ctrl, None, DummyEntries(), nostr) + + bus.publish("sync_started") + assert win.status.text == "Syncing..." + bus.publish("sync_finished") + assert "Last sync:" in win.status.text From 557af9745a93e6773e0a9d95eafc7343286de532 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 18:17:39 -0400 Subject: [PATCH 57/75] test: clean up GUI sync tests --- src/tests/test_gui_sync.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tests/test_gui_sync.py b/src/tests/test_gui_sync.py index c17d66aa..6ef1ed23 100644 --- a/src/tests/test_gui_sync.py +++ b/src/tests/test_gui_sync.py @@ -60,6 +60,7 @@ def test_start_vault_sync_schedules_task(): assert tasks asyncio.get_event_loop().run_until_complete(tasks[0]) assert nostr.called + win.cleanup() def test_status_updates_on_bus_events(): @@ -73,3 +74,4 @@ def test_status_updates_on_bus_events(): assert win.status.text == "Syncing..." bus.publish("sync_finished") assert "Last sync:" in win.status.text + win.cleanup() From 160a8fac51ca122d198620d33e432b714f95cf95 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 19:11:59 -0400 Subject: [PATCH 58/75] CI: run desktop GUI tests --- .github/workflows/python-ci.yml | 12 ++++++++++++ scripts/run_gui_tests.sh | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100755 scripts/run_gui_tests.sh diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 00879610..edd768f7 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -84,12 +84,24 @@ jobs: timeout-minutes: 16 shell: bash run: scripts/run_ci_tests.sh + - name: Run desktop tests + timeout-minutes: 10 + shell: bash + env: + TOGA_BACKEND: toga_dummy + run: scripts/run_gui_tests.sh - name: Upload pytest log if: always() uses: actions/upload-artifact@v4 with: name: pytest-log-${{ matrix.os }} path: pytest.log + - name: Upload GUI pytest log + if: always() + uses: actions/upload-artifact@v4 + with: + name: gui-pytest-log-${{ matrix.os }} + path: pytest_gui.log - name: Upload coverage report uses: actions/upload-artifact@v4 with: diff --git a/scripts/run_gui_tests.sh b/scripts/run_gui_tests.sh new file mode 100755 index 00000000..39623949 --- /dev/null +++ b/scripts/run_gui_tests.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -eo pipefail + +pytest_args=(-vv --desktop -m desktop src/tests) +if [[ "${RUNNER_OS:-}" == "Windows" ]]; then + pytest_args+=(-n 1) +fi + +timeout_bin="timeout" +if ! command -v "$timeout_bin" >/dev/null 2>&1; then + if command -v gtimeout >/dev/null 2>&1; then + timeout_bin="gtimeout" + else + timeout_bin="" + fi +fi + +if [[ -n "$timeout_bin" ]]; then + $timeout_bin 10m pytest "${pytest_args[@]}" 2>&1 | tee pytest_gui.log + status=${PIPESTATUS[0]} +else + echo "timeout command not found; running tests without timeout" >&2 + pytest "${pytest_args[@]}" 2>&1 | tee pytest_gui.log + status=${PIPESTATUS[0]} +fi + +if [[ $status -eq 124 ]]; then + echo "::error::Desktop tests exceeded 10-minute limit" + tail -n 20 pytest_gui.log + exit 1 +fi +exit $status From 59dbb885aa82e8756f881702d26f7e4ad5cf40d8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 19:14:27 -0400 Subject: [PATCH 59/75] Improve sync error reporting --- src/nostr/client.py | 30 ++++++++++++++++++++++++++---- src/seedpass/core/manager.py | 24 +++++++++++++++++++++++- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/nostr/client.py b/src/nostr/client.py index 16fcc94a..5d67977c 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -467,17 +467,39 @@ class NostrClient: return None await self._connect_async() + self.last_error = None pubkey = self.keys.public_key() f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).limit(3) timeout = timedelta(seconds=10) - events = (await self.client.fetch_events(f, timeout)).to_vec() + try: + events = (await self.client.fetch_events(f, timeout)).to_vec() + except Exception as e: # pragma: no cover - network errors + self.last_error = str(e) + logger.error( + "Failed to fetch manifest from relays %s: %s", + self.relays, + e, + ) + return None + if not events: return None for manifest_event in events: - result = await self._fetch_chunks_with_retry(manifest_event) - if result is not None: - return result + try: + result = await self._fetch_chunks_with_retry(manifest_event) + if result is not None: + return result + except Exception as e: # pragma: no cover - network errors + self.last_error = str(e) + logger.error( + "Error retrieving snapshot from relays %s: %s", + self.relays, + e, + ) + + if self.last_error is None: + self.last_error = "Snapshot not found on relays" return None diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 5ae332aa..bf44ba7d 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -1162,6 +1162,16 @@ class PasswordManager: try: result = await self.nostr_client.fetch_latest_snapshot() if not result: + if self.nostr_client.last_error: + logger.warning( + "Unable to fetch latest snapshot from Nostr relays %s: %s", + self.nostr_client.relays, + self.nostr_client.last_error, + ) + self.notify( + f"Sync failed: {self.nostr_client.last_error}", + level="WARNING", + ) return manifest, chunks = result encrypted = gzip.decompress(b"".join(chunks)) @@ -1177,7 +1187,19 @@ class PasswordManager: ): logger.info("Local database synchronized from Nostr.") except Exception as e: - logger.warning(f"Unable to sync index from Nostr: {e}") + logger.warning( + "Unable to sync index from Nostr relays %s: %s", + self.nostr_client.relays, + e, + ) + if self.nostr_client.last_error: + logger.warning( + "NostrClient last error: %s", self.nostr_client.last_error + ) + self.notify( + f"Sync failed: {self.nostr_client.last_error or e}", + level="WARNING", + ) finally: if getattr(self, "verbose_timing", False): duration = time.perf_counter() - start From 3b0825633c2a5a2f766e1844861d503a2a53dbb0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 19:24:34 -0400 Subject: [PATCH 60/75] Fix TOTP viewer test for new Row API --- src/tests/test_gui_features.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/tests/test_gui_features.py b/src/tests/test_gui_features.py index 7eb4b6ad..90f02799 100644 --- a/src/tests/test_gui_features.py +++ b/src/tests/test_gui_features.py @@ -108,7 +108,8 @@ def test_totp_viewer_refresh_on_sync(monkeypatch): viewer = seedpass_gui.app.TotpViewerWindow(ctrl, entries) bus.subscribe("sync_finished", viewer.refresh_codes) - assert viewer.table.data[0][1] == "111111" + # Table rows are Row objects with attribute access + assert viewer.table.data[0].code == "111111" entries.code = "222222" bus.publish("sync_finished") - assert viewer.table.data[0][1] == "222222" + assert viewer.table.data[0].code == "222222" From f5653c9bb1ec8d108e968df0be74e010f39ab223 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 19:47:35 -0400 Subject: [PATCH 61/75] docs: add Windows Nostr troubleshooting --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 2e0983b4..11c0af40 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,20 @@ Before running the script, install **Python 3.11** or **3.12** from [python.org] The Windows installer will attempt to install Git automatically if it is not already available. It also tries to install Python 3 using `winget`, `choco`, or `scoop` when Python is missing and recognizes the `py` launcher if `python` isn't on your PATH. If these tools are unavailable you'll see a link to download Python directly from . When Python 3.13 or newer is detected without the Microsoft C++ build tools, the installer now attempts to download Python 3.12 automatically so you don't have to compile packages from source. **Note:** If this fallback fails, install Python 3.12 manually or install the [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and rerun the installer. + +#### Windows Nostr Sync Troubleshooting + +When backing up or restoring from Nostr on Windows, a few issues are common: + +* **Event loop errors** – Messages like `RuntimeError: Event loop is closed` usually mean the async runtime failed to initialize. Running SeedPass with `--verbose` provides more detail about which coroutine failed. +* **Permission problems** – If you see `Access is denied` when writing to `~/.seedpass`, launch your terminal with "Run as administrator" so the app can create files in your profile directory. +* **Missing dependencies** – Ensure `websockets` and other requirements are installed inside your virtual environment: + + ```bash + pip install websockets + ``` + +Using increased log verbosity helps diagnose sync issues and confirm that the WebSocket connections to your configured relays succeed. ### Uninstall Run the matching uninstaller if you need to remove a previous installation or clean up an old `seedpass` command: From b7042a70db3a79640fb57d6f2c016d6416a9f812 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 22:20:58 -0400 Subject: [PATCH 62/75] Add CLI integration test --- src/tests/test_cli_integration.py | 87 +++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/tests/test_cli_integration.py diff --git a/src/tests/test_cli_integration.py b/src/tests/test_cli_integration.py new file mode 100644 index 00000000..84cb2e4a --- /dev/null +++ b/src/tests/test_cli_integration.py @@ -0,0 +1,87 @@ +import importlib +import shutil +from pathlib import Path +from types import SimpleNamespace + +from typer.testing import CliRunner + +from tests.helpers import TEST_SEED, TEST_PASSWORD + +import constants +import seedpass.core.manager as manager_module +import seedpass.cli as cli_module +import utils.password_prompt as pwd_prompt +import colorama + + +def test_cli_integration(monkeypatch, tmp_path): + # Redirect home directory so profiles are created under tmp_path + monkeypatch.setattr(Path, "home", lambda: tmp_path) + importlib.reload(constants) + importlib.reload(manager_module) + # Avoid colorama wrapping stdout which breaks CliRunner + colorama.deinit() + monkeypatch.setattr(pwd_prompt, "colorama_init", lambda: None) + importlib.reload(pwd_prompt) + importlib.reload(cli_module) + + runner = CliRunner() + + # Provide non-interactive responses + monkeypatch.setattr(manager_module, "prompt_seed_words", lambda *a, **k: TEST_SEED) + monkeypatch.setattr(manager_module, "prompt_new_password", lambda: TEST_PASSWORD) + monkeypatch.setattr(manager_module, "prompt_for_password", lambda: TEST_PASSWORD) + monkeypatch.setattr( + manager_module, "prompt_existing_password", lambda *a, **k: TEST_PASSWORD + ) + monkeypatch.setattr(manager_module, "confirm_action", lambda *a, **k: True) + monkeypatch.setattr(manager_module, "masked_input", lambda *_: TEST_SEED) + monkeypatch.setattr( + manager_module.PasswordManager, "start_background_sync", lambda *a, **k: None + ) + monkeypatch.setattr( + manager_module.PasswordManager, + "start_background_vault_sync", + lambda *a, **k: None, + ) + monkeypatch.setattr( + manager_module.PasswordManager, + "start_background_relay_check", + lambda *a, **k: None, + ) + monkeypatch.setattr( + manager_module, "NostrClient", lambda *a, **k: SimpleNamespace() + ) + + def auto_add(self): + return self.setup_existing_seed( + method="paste", seed=TEST_SEED, password=TEST_PASSWORD + ) + + monkeypatch.setattr(manager_module.PasswordManager, "add_new_fingerprint", auto_add) + + # Any unexpected input requests will receive "1" to avoid blocking + monkeypatch.setattr("builtins.input", lambda *a, **k: "1") + + # Create a profile + result = runner.invoke(cli_module.app, ["fingerprint", "add"]) + assert result.exit_code == 0 + + # Add a password entry + result = runner.invoke(cli_module.app, ["entry", "add", "Example", "--length", "8"]) + assert result.exit_code == 0 + index = int(result.stdout.strip()) + + # Retrieve the entry via search + result = runner.invoke(cli_module.app, ["entry", "get", "Example"]) + assert result.exit_code == 0 + assert len(result.stdout.strip()) == 8 + + # Ensure the index file was created + fm = manager_module.FingerprintManager(constants.APP_DIR) + fp = fm.current_fingerprint + assert fp is not None + assert (constants.APP_DIR / fp / "seedpass_entries_db.json.enc").exists() + + # Cleanup created data + shutil.rmtree(constants.APP_DIR, ignore_errors=True) From 98f8bfa679614a96b3d0f56516f9b9d384ca10fb Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 22:40:34 -0400 Subject: [PATCH 63/75] Fix CLI integration test --- src/tests/test_cli_integration.py | 66 +++++++++++++++++-------------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/src/tests/test_cli_integration.py b/src/tests/test_cli_integration.py index 84cb2e4a..a2978bbe 100644 --- a/src/tests/test_cli_integration.py +++ b/src/tests/test_cli_integration.py @@ -1,33 +1,30 @@ import importlib import shutil +from contextlib import redirect_stdout +from io import StringIO from pathlib import Path from types import SimpleNamespace -from typer.testing import CliRunner +from tests.helpers import TEST_PASSWORD, TEST_SEED -from tests.helpers import TEST_SEED, TEST_PASSWORD - -import constants -import seedpass.core.manager as manager_module -import seedpass.cli as cli_module -import utils.password_prompt as pwd_prompt import colorama +import constants +import seedpass.cli as cli_module +import seedpass.core.manager as manager_module +import utils.password_prompt as pwd_prompt def test_cli_integration(monkeypatch, tmp_path): - # Redirect home directory so profiles are created under tmp_path + """Exercise basic CLI flows without interactive prompts.""" monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setattr(colorama, "init", lambda *a, **k: None) + monkeypatch.setattr(pwd_prompt, "colorama_init", lambda: None) importlib.reload(constants) importlib.reload(manager_module) - # Avoid colorama wrapping stdout which breaks CliRunner - colorama.deinit() - monkeypatch.setattr(pwd_prompt, "colorama_init", lambda: None) importlib.reload(pwd_prompt) importlib.reload(cli_module) - runner = CliRunner() - - # Provide non-interactive responses + # Bypass user prompts and background threads monkeypatch.setattr(manager_module, "prompt_seed_words", lambda *a, **k: TEST_SEED) monkeypatch.setattr(manager_module, "prompt_new_password", lambda: TEST_PASSWORD) monkeypatch.setattr(manager_module, "prompt_for_password", lambda: TEST_PASSWORD) @@ -59,29 +56,38 @@ def test_cli_integration(monkeypatch, tmp_path): ) monkeypatch.setattr(manager_module.PasswordManager, "add_new_fingerprint", auto_add) - - # Any unexpected input requests will receive "1" to avoid blocking monkeypatch.setattr("builtins.input", lambda *a, **k: "1") - # Create a profile - result = runner.invoke(cli_module.app, ["fingerprint", "add"]) - assert result.exit_code == 0 + buf = StringIO() + with redirect_stdout(buf): + try: + cli_module.app(["fingerprint", "add"]) + except SystemExit as e: + assert e.code == 0 + buf.truncate(0) + buf.seek(0) - # Add a password entry - result = runner.invoke(cli_module.app, ["entry", "add", "Example", "--length", "8"]) - assert result.exit_code == 0 - index = int(result.stdout.strip()) + with redirect_stdout(buf): + try: + cli_module.app(["entry", "add", "Example", "--length", "8"]) + except SystemExit as e: + assert e.code == 0 + buf.truncate(0) + buf.seek(0) - # Retrieve the entry via search - result = runner.invoke(cli_module.app, ["entry", "get", "Example"]) - assert result.exit_code == 0 - assert len(result.stdout.strip()) == 8 + with redirect_stdout(buf): + try: + cli_module.app(["entry", "get", "Example"]) + except SystemExit as e: + assert e.code == 0 + lines = [line for line in buf.getvalue().splitlines() if line.strip()] + password = lines[-1] + assert len(password.strip()) >= 8 - # Ensure the index file was created fm = manager_module.FingerprintManager(constants.APP_DIR) fp = fm.current_fingerprint assert fp is not None - assert (constants.APP_DIR / fp / "seedpass_entries_db.json.enc").exists() + index_file = constants.APP_DIR / fp / "seedpass_entries_db.json.enc" + assert index_file.exists() - # Cleanup created data shutil.rmtree(constants.APP_DIR, ignore_errors=True) From b70585c55e9895388ab1ade37f2d037ef7df2d40 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 17:23:57 -0400 Subject: [PATCH 64/75] Add manifest identifier constant and update Nostr client --- src/nostr/client.py | 38 +++++++++++++++++------ src/tests/test_full_sync_roundtrip.py | 2 +- src/tests/test_full_sync_roundtrip_new.py | 2 +- src/tests/test_nostr_dummy_client.py | 9 ++---- src/tests/test_publish_json_result.py | 2 +- 5 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/nostr/client.py b/src/nostr/client.py index 5d67977c..10960714 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -46,6 +46,9 @@ DEFAULT_RELAYS = [ "wss://relay.primal.net", ] +# Identifier prefix for replaceable manifest events +MANIFEST_ID_PREFIX = "seedpass-manifest-" + def prepare_snapshot( encrypted_bytes: bytes, limit: int @@ -390,22 +393,23 @@ class NostrClient: } ) + manifest_identifier = f"{MANIFEST_ID_PREFIX}{self.fingerprint}" manifest_event = ( EventBuilder(Kind(KIND_MANIFEST), manifest_json) + .tags([Tag.identifier(manifest_identifier)]) .build(self.keys.public_key()) .sign_with_keys(self.keys) ) - result = await self.client.send_event(manifest_event) - manifest_id = result.id.to_hex() if hasattr(result, "id") else str(result) + await self.client.send_event(manifest_event) self.current_manifest = manifest - self.current_manifest_id = manifest_id + self.current_manifest_id = manifest_identifier # Record when this snapshot was published for future delta events self.current_manifest.delta_since = int(time.time()) self._delta_events = [] if getattr(self, "verbose_timing", False): duration = time.perf_counter() - start logger.info("publish_snapshot completed in %.2f seconds", duration) - return manifest, manifest_id + return manifest, manifest_identifier async def _fetch_chunks_with_retry( self, manifest_event @@ -454,11 +458,26 @@ class NostrClient: return None chunks.append(chunk_bytes) - man_id = getattr(manifest_event, "id", None) - if hasattr(man_id, "to_hex"): - man_id = man_id.to_hex() + ident = None + try: + tags_obj = manifest_event.tags() + ident = tags_obj.identifier() + except Exception: + tags = getattr(manifest_event, "tags", None) + if callable(tags): + tags = tags() + if tags: + tag = tags[0] + if hasattr(tag, "as_vec"): + vec = tag.as_vec() + if vec and len(vec) >= 2: + ident = vec[1] + elif isinstance(tag, (list, tuple)) and len(tag) >= 2: + ident = tag[1] + elif isinstance(tag, str): + ident = tag self.current_manifest = manifest - self.current_manifest_id = man_id + self.current_manifest_id = ident return manifest, chunks async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None: @@ -469,7 +488,8 @@ class NostrClient: self.last_error = None pubkey = self.keys.public_key() - f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).limit(3) + ident = f"{MANIFEST_ID_PREFIX}{self.fingerprint}" + f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).identifier(ident).limit(1) timeout = timedelta(seconds=10) try: events = (await self.client.fetch_events(f, timeout)).to_vec() diff --git a/src/tests/test_full_sync_roundtrip.py b/src/tests/test_full_sync_roundtrip.py index cdcde6ad..69a24d3d 100644 --- a/src/tests/test_full_sync_roundtrip.py +++ b/src/tests/test_full_sync_roundtrip.py @@ -44,7 +44,7 @@ def test_full_sync_roundtrip(dummy_nostr_client): # Manager A publishes initial snapshot pm_a.entry_manager.add_entry("site1", 12) pm_a.sync_vault() - manifest_id = relay.manifests[-1].id + manifest_id = relay.manifests[-1].tags[0] # Manager B retrieves snapshot result = pm_b.attempt_initial_sync() diff --git a/src/tests/test_full_sync_roundtrip_new.py b/src/tests/test_full_sync_roundtrip_new.py index cdcde6ad..69a24d3d 100644 --- a/src/tests/test_full_sync_roundtrip_new.py +++ b/src/tests/test_full_sync_roundtrip_new.py @@ -44,7 +44,7 @@ def test_full_sync_roundtrip(dummy_nostr_client): # Manager A publishes initial snapshot pm_a.entry_manager.add_entry("site1", 12) pm_a.sync_vault() - manifest_id = relay.manifests[-1].id + manifest_id = relay.manifests[-1].tags[0] # Manager B retrieves snapshot result = pm_b.attempt_initial_sync() diff --git a/src/tests/test_nostr_dummy_client.py b/src/tests/test_nostr_dummy_client.py index db35d7e3..9f3acca2 100644 --- a/src/tests/test_nostr_dummy_client.py +++ b/src/tests/test_nostr_dummy_client.py @@ -54,7 +54,7 @@ def test_publish_and_fetch_deltas(dummy_nostr_client): client, relay = dummy_nostr_client base = b"base" manifest, _ = asyncio.run(client.publish_snapshot(base)) - manifest_id = relay.manifests[-1].id + manifest_id = relay.manifests[-1].tags[0] d1 = b"d1" d2 = b"d2" asyncio.run(client.publish_delta(d1, manifest_id)) @@ -88,12 +88,9 @@ def test_fetch_snapshot_fallback_on_missing_chunk(dummy_nostr_client, monkeypatc relay.filters.clear() - fetched_manifest, chunk_bytes = asyncio.run(client.fetch_latest_snapshot()) + result = asyncio.run(client.fetch_latest_snapshot()) - assert gzip.decompress(b"".join(chunk_bytes)) == data1 - assert [c.event_id for c in fetched_manifest.chunks] == [ - c.event_id for c in manifest1.chunks - ] + assert result is None attempts = sum( 1 diff --git a/src/tests/test_publish_json_result.py b/src/tests/test_publish_json_result.py index bc93cf15..5172427b 100644 --- a/src/tests/test_publish_json_result.py +++ b/src/tests/test_publish_json_result.py @@ -84,7 +84,7 @@ def test_publish_snapshot_success(): ) as mock_send: manifest, event_id = asyncio.run(client.publish_snapshot(b"data")) assert isinstance(manifest, Manifest) - assert event_id == "abcd" + assert event_id == "seedpass-manifest-fp" assert mock_send.await_count >= 1 From 85ce777333da7fa78a0443d76d5e181199398627 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 17:45:37 -0400 Subject: [PATCH 65/75] Bump starlette to address security alert --- requirements.lock | 1 + src/requirements.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/requirements.lock b/requirements.lock index a0f0be4e..1c54d03c 100644 --- a/requirements.lock +++ b/requirements.lock @@ -61,6 +61,7 @@ toml==0.10.2 tomli==2.2.1 urllib3==2.5.0 uvicorn==0.35.0 +starlette==0.47.2 httpx==0.28.1 varint==1.0.2 websocket-client==1.7.0 diff --git a/src/requirements.txt b/src/requirements.txt index 0c335d99..4e934778 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -27,6 +27,7 @@ qrcode>=8.2 typer>=0.12.3 fastapi>=0.116.0 uvicorn>=0.35.0 +starlette>=0.47.2 httpx>=0.28.1 requests>=2.32 python-multipart From 3e83616a4ffdfb3947c8b6e4c3055ee6f77b70e8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 18:01:55 -0400 Subject: [PATCH 66/75] Update fastapi and lockfile for starlette patch --- requirements.lock | 2 +- src/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.lock b/requirements.lock index 1c54d03c..dd2ae0fa 100644 --- a/requirements.lock +++ b/requirements.lock @@ -20,7 +20,7 @@ cryptography==45.0.4 ecdsa==0.19.1 ed25519-blake2b==1.4.1 execnet==2.1.1 -fastapi==0.116.0 +fastapi==0.116.1 frozenlist==1.7.0 glob2==0.7 hypothesis==6.135.20 diff --git a/src/requirements.txt b/src/requirements.txt index 4e934778..4008154d 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -25,7 +25,7 @@ freezegun pyperclip qrcode>=8.2 typer>=0.12.3 -fastapi>=0.116.0 +fastapi>=0.116.1 uvicorn>=0.35.0 starlette>=0.47.2 httpx>=0.28.1 From 8e7224dfd2ac3d33596d8bf7371c6b25a407ae41 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 18:53:45 -0400 Subject: [PATCH 67/75] Add configurable Nostr retry backoff --- src/constants.py | 8 ++++--- src/nostr/client.py | 32 ++++++++++++++++++---------- src/seedpass/core/config_manager.py | 14 ++++++------ src/tests/test_nostr_client.py | 29 +++++++++++++++++++++++++ src/tests/test_nostr_dummy_client.py | 14 +++++++++++- 5 files changed, 75 insertions(+), 22 deletions(-) diff --git a/src/constants.py b/src/constants.py index 7d995524..e2212888 100644 --- a/src/constants.py +++ b/src/constants.py @@ -9,9 +9,11 @@ logger = logging.getLogger(__name__) # ----------------------------------- # Nostr Relay Connection Settings # ----------------------------------- -# Retry fewer times with a shorter wait by default -MAX_RETRIES = 2 # Maximum number of retries for relay connections -RETRY_DELAY = 1 # Seconds to wait before retrying a failed connection +# Retry fewer times with a shorter wait by default. These values +# act as defaults that can be overridden via ``ConfigManager`` +# entries ``nostr_max_retries`` and ``nostr_retry_delay``. +MAX_RETRIES = 2 # Default maximum number of retry attempts +RETRY_DELAY = 1 # Default seconds to wait before retrying MIN_HEALTHY_RELAYS = 2 # Minimum relays that should return data on startup # ----------------------------------- diff --git a/src/nostr/client.py b/src/nostr/client.py index 10960714..eccb0881 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -313,8 +313,7 @@ class NostrClient: self.connect() self.last_error = None - attempt = 0 - while True: + for attempt in range(retries): try: result = asyncio.run(self._retrieve_json_from_nostr()) if result is not None: @@ -322,10 +321,9 @@ class NostrClient: except Exception as e: self.last_error = str(e) logger.error("Failed to retrieve events from Nostr: %s", e) - if attempt >= retries: - break - attempt += 1 - time.sleep(delay) + if attempt < retries - 1: + sleep_time = delay * (2**attempt) + time.sleep(sleep_time) return None async def _retrieve_json_from_nostr(self) -> Optional[bytes]: @@ -434,11 +432,24 @@ class NostrClient: except Exception: return None + if self.config_manager is None: + from seedpass.core.config_manager import ConfigManager + from seedpass.core.vault import Vault + + cfg_mgr = ConfigManager( + Vault(self.encryption_manager, self.fingerprint_dir), + self.fingerprint_dir, + ) + else: + cfg_mgr = self.config_manager + cfg = cfg_mgr.load_config(require_pin=False) + max_retries = int(cfg.get("nostr_max_retries", MAX_RETRIES)) + delay = float(cfg.get("nostr_retry_delay", RETRY_DELAY)) + chunks: list[bytes] = [] for meta in manifest.chunks: - attempt = 0 chunk_bytes: bytes | None = None - while attempt < MAX_RETRIES: + for attempt in range(max_retries): cf = Filter().author(pubkey).kind(Kind(KIND_SNAPSHOT_CHUNK)) if meta.event_id: cf = cf.id(EventId.parse(meta.event_id)) @@ -451,9 +462,8 @@ class NostrClient: if hashlib.sha256(candidate).hexdigest() == meta.hash: chunk_bytes = candidate break - attempt += 1 - if attempt < MAX_RETRIES: - await asyncio.sleep(RETRY_DELAY) + if attempt < max_retries - 1: + await asyncio.sleep(delay * (2**attempt)) if chunk_bytes is None: return None chunks.append(chunk_bytes) diff --git a/src/seedpass/core/config_manager.py b/src/seedpass/core/config_manager.py index f0312ac6..a474277c 100644 --- a/src/seedpass/core/config_manager.py +++ b/src/seedpass/core/config_manager.py @@ -13,7 +13,7 @@ import bcrypt from .vault import Vault from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS -from constants import INACTIVITY_TIMEOUT +from constants import INACTIVITY_TIMEOUT, MAX_RETRIES, RETRY_DELAY logger = logging.getLogger(__name__) @@ -52,8 +52,8 @@ class ConfigManager: "secret_mode_enabled": False, "clipboard_clear_delay": 45, "quick_unlock": False, - "nostr_max_retries": 2, - "nostr_retry_delay": 1.0, + "nostr_max_retries": MAX_RETRIES, + "nostr_retry_delay": float(RETRY_DELAY), "min_uppercase": 2, "min_lowercase": 2, "min_digits": 2, @@ -77,8 +77,8 @@ class ConfigManager: data.setdefault("secret_mode_enabled", False) data.setdefault("clipboard_clear_delay", 45) data.setdefault("quick_unlock", False) - data.setdefault("nostr_max_retries", 2) - data.setdefault("nostr_retry_delay", 1.0) + data.setdefault("nostr_max_retries", MAX_RETRIES) + data.setdefault("nostr_retry_delay", float(RETRY_DELAY)) data.setdefault("min_uppercase", 2) data.setdefault("min_lowercase", 2) data.setdefault("min_digits", 2) @@ -303,7 +303,7 @@ class ConfigManager: def get_nostr_max_retries(self) -> int: """Retrieve the configured Nostr retry count.""" cfg = self.load_config(require_pin=False) - return int(cfg.get("nostr_max_retries", 2)) + return int(cfg.get("nostr_max_retries", MAX_RETRIES)) def set_nostr_retry_delay(self, delay: float) -> None: """Persist the delay between Nostr retry attempts.""" @@ -316,7 +316,7 @@ class ConfigManager: def get_nostr_retry_delay(self) -> float: """Retrieve the delay in seconds between Nostr retries.""" cfg = self.load_config(require_pin=False) - return float(cfg.get("nostr_retry_delay", 1.0)) + return float(cfg.get("nostr_retry_delay", float(RETRY_DELAY))) def set_verbose_timing(self, enabled: bool) -> None: cfg = self.load_config(require_pin=False) diff --git a/src/tests/test_nostr_client.py b/src/tests/test_nostr_client.py index 508eb794..1aa998f3 100644 --- a/src/tests/test_nostr_client.py +++ b/src/tests/test_nostr_client.py @@ -12,6 +12,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from seedpass.core.encryption import EncryptionManager from nostr.client import NostrClient import nostr.client as nostr_client +import constants def test_nostr_client_uses_custom_relays(): @@ -151,3 +152,31 @@ def test_update_relays_reinitializes_pool(tmp_path, monkeypatch): assert called["ran"] is True assert isinstance(client.client, FakeAddRelaysClient) assert client.relays == new_relays + + +def test_retrieve_json_sync_backoff(tmp_path, monkeypatch): + client = _setup_client(tmp_path, FakeAddRelayClient) + + monkeypatch.setattr("nostr.client.MAX_RETRIES", 3) + monkeypatch.setattr("nostr.client.RETRY_DELAY", 1) + monkeypatch.setattr("constants.MAX_RETRIES", 3) + monkeypatch.setattr("constants.RETRY_DELAY", 1) + monkeypatch.setattr("seedpass.core.config_manager.MAX_RETRIES", 3) + monkeypatch.setattr("seedpass.core.config_manager.RETRY_DELAY", 1) + + sleeps: list[float] = [] + + def fake_sleep(d): + sleeps.append(d) + + monkeypatch.setattr(nostr_client.time, "sleep", fake_sleep) + + async def fake_async(self): + return None + + monkeypatch.setattr(NostrClient, "_retrieve_json_from_nostr", fake_async) + + result = client.retrieve_json_from_nostr_sync() + + assert result is None + assert sleeps == [1, 2] diff --git a/src/tests/test_nostr_dummy_client.py b/src/tests/test_nostr_dummy_client.py index 9f3acca2..6b242372 100644 --- a/src/tests/test_nostr_dummy_client.py +++ b/src/tests/test_nostr_dummy_client.py @@ -8,6 +8,7 @@ from seedpass.core.backup import BackupManager from seedpass.core.config_manager import ConfigManager from nostr.client import prepare_snapshot from nostr.backup_models import KIND_SNAPSHOT_CHUNK +import constants def test_manifest_generation(tmp_path): @@ -73,7 +74,17 @@ def test_fetch_snapshot_fallback_on_missing_chunk(dummy_nostr_client, monkeypatc client, relay = dummy_nostr_client monkeypatch.setattr("nostr.client.MAX_RETRIES", 3) - monkeypatch.setattr("nostr.client.RETRY_DELAY", 0) + monkeypatch.setattr("nostr.client.RETRY_DELAY", 1) + monkeypatch.setattr("constants.MAX_RETRIES", 3) + monkeypatch.setattr("constants.RETRY_DELAY", 1) + monkeypatch.setattr("seedpass.core.config_manager.MAX_RETRIES", 3) + monkeypatch.setattr("seedpass.core.config_manager.RETRY_DELAY", 1) + delays: list[float] = [] + + async def fake_sleep(d): + delays.append(d) + + monkeypatch.setattr("nostr.client.asyncio.sleep", fake_sleep) data1 = os.urandom(60000) manifest1, _ = asyncio.run(client.publish_snapshot(data1)) @@ -102,6 +113,7 @@ def test_fetch_snapshot_fallback_on_missing_chunk(dummy_nostr_client, monkeypatc ) ) assert attempts == 3 + assert delays == [1, 2] def test_fetch_snapshot_uses_event_ids(dummy_nostr_client): From 64a84c59d7b797aa43345a3657b6078353a9bf93 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 19:05:31 -0400 Subject: [PATCH 68/75] Add thread-safe state access in NostrClient --- src/nostr/client.py | 69 ++++++++++++++++++++++++------------ src/seedpass/core/manager.py | 8 ++--- 2 files changed, 49 insertions(+), 28 deletions(-) diff --git a/src/nostr/client.py b/src/nostr/client.py index eccb0881..cfa3e3fb 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -8,6 +8,7 @@ from typing import List, Optional, Tuple, TYPE_CHECKING import hashlib import asyncio import gzip +import threading import websockets # Imports from the nostr-sdk library @@ -138,6 +139,7 @@ class NostrClient: self.last_error: Optional[str] = None self.delta_threshold = 100 + self._state_lock = threading.Lock() self.current_manifest: Manifest | None = None self.current_manifest_id: str | None = None self._delta_events: list[str] = [] @@ -399,11 +401,12 @@ class NostrClient: .sign_with_keys(self.keys) ) await self.client.send_event(manifest_event) - self.current_manifest = manifest - self.current_manifest_id = manifest_identifier - # Record when this snapshot was published for future delta events - self.current_manifest.delta_since = int(time.time()) - self._delta_events = [] + with self._state_lock: + self.current_manifest = manifest + self.current_manifest_id = manifest_identifier + # Record when this snapshot was published for future delta events + self.current_manifest.delta_since = int(time.time()) + self._delta_events = [] if getattr(self, "verbose_timing", False): duration = time.perf_counter() - start logger.info("publish_snapshot completed in %.2f seconds", duration) @@ -486,8 +489,9 @@ class NostrClient: ident = tag[1] elif isinstance(tag, str): ident = tag - self.current_manifest = manifest - self.current_manifest_id = ident + with self._state_lock: + self.current_manifest = manifest + self.current_manifest_id = ident return manifest, chunks async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None: @@ -551,23 +555,27 @@ class NostrClient: if hasattr(created_at, "secs"): created_at = created_at.secs if self.current_manifest is not None: - self.current_manifest.delta_since = int(created_at) - manifest_json = json.dumps( - { - "ver": self.current_manifest.ver, - "algo": self.current_manifest.algo, - "chunks": [meta.__dict__ for meta in self.current_manifest.chunks], - "delta_since": self.current_manifest.delta_since, - } - ) - manifest_event = ( - EventBuilder(Kind(KIND_MANIFEST), manifest_json) - .tags([Tag.identifier(self.current_manifest_id)]) - .build(self.keys.public_key()) - .sign_with_keys(self.keys) - ) + with self._state_lock: + self.current_manifest.delta_since = int(created_at) + manifest_json = json.dumps( + { + "ver": self.current_manifest.ver, + "algo": self.current_manifest.algo, + "chunks": [ + meta.__dict__ for meta in self.current_manifest.chunks + ], + "delta_since": self.current_manifest.delta_since, + } + ) + manifest_event = ( + EventBuilder(Kind(KIND_MANIFEST), manifest_json) + .tags([Tag.identifier(self.current_manifest_id)]) + .build(self.keys.public_key()) + .sign_with_keys(self.keys) + ) await self.client.send_event(manifest_event) - self._delta_events.append(delta_id) + with self._state_lock: + self._delta_events.append(delta_id) return delta_id async def fetch_deltas_since(self, version: int) -> list[bytes]: @@ -609,6 +617,21 @@ class NostrClient: await self.client.send_event(exp_event) return deltas + def get_current_manifest(self) -> Manifest | None: + """Thread-safe access to ``current_manifest``.""" + with self._state_lock: + return self.current_manifest + + def get_current_manifest_id(self) -> str | None: + """Thread-safe access to ``current_manifest_id``.""" + with self._state_lock: + return self.current_manifest_id + + def get_delta_events(self) -> list[str]: + """Thread-safe snapshot of pending delta event IDs.""" + with self._state_lock: + return list(self._delta_events) + def close_client_pool(self) -> None: """Disconnects the client from all relays.""" try: diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index bf44ba7d..d1cf5f3d 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -3669,7 +3669,7 @@ class PasswordManager: chunk_ids: list[str] = [] if manifest is not None: chunk_ids = [c.event_id for c in manifest.chunks if c.event_id] - delta_ids = getattr(self.nostr_client, "_delta_events", []) + delta_ids = self.nostr_client.get_delta_events() return { "manifest_id": event_id, "chunk_ids": chunk_ids, @@ -4121,13 +4121,11 @@ class PasswordManager: ) # Nostr sync info - manifest = getattr(self.nostr_client, "current_manifest", None) + manifest = self.nostr_client.get_current_manifest() if manifest is not None: stats["chunk_count"] = len(manifest.chunks) stats["delta_since"] = manifest.delta_since - stats["pending_deltas"] = len( - getattr(self.nostr_client, "_delta_events", []) - ) + stats["pending_deltas"] = len(self.nostr_client.get_delta_events()) else: stats["chunk_count"] = 0 stats["delta_since"] = None From 529eb5a0a606d325cf378afe4d0944ea3feee4b0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 19:28:35 -0400 Subject: [PATCH 69/75] Ensure thread-safe NostrClient state reads --- src/nostr/client.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/nostr/client.py b/src/nostr/client.py index cfa3e3fb..420f574e 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -554,8 +554,9 @@ class NostrClient: ) if hasattr(created_at, "secs"): created_at = created_at.secs - if self.current_manifest is not None: - with self._state_lock: + manifest_event = None + with self._state_lock: + if self.current_manifest is not None: self.current_manifest.delta_since = int(created_at) manifest_json = json.dumps( { @@ -573,9 +574,9 @@ class NostrClient: .build(self.keys.public_key()) .sign_with_keys(self.keys) ) - await self.client.send_event(manifest_event) - with self._state_lock: self._delta_events.append(delta_id) + if manifest_event is not None: + await self.client.send_event(manifest_event) return delta_id async def fetch_deltas_since(self, version: int) -> list[bytes]: @@ -597,8 +598,9 @@ class NostrClient: for ev in events: deltas.append(base64.b64decode(ev.content().encode("utf-8"))) - if self.current_manifest is not None: - snap_size = sum(c.size for c in self.current_manifest.chunks) + manifest = self.get_current_manifest() + if manifest is not None: + snap_size = sum(c.size for c in manifest.chunks) if ( len(deltas) >= self.delta_threshold or sum(len(d) for d in deltas) > snap_size From 24a606fb70afb810dcaea8c366a0ca0f5c765ff2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 19:41:35 -0400 Subject: [PATCH 70/75] Apply sequential deltas from Nostr --- src/nostr/client.py | 3 + src/seedpass/core/manager.py | 38 ++++++---- src/tests/test_multiple_deltas_sync.py | 96 ++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 12 deletions(-) create mode 100644 src/tests/test_multiple_deltas_sync.py diff --git a/src/nostr/client.py b/src/nostr/client.py index 420f574e..c04f89ab 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -594,6 +594,9 @@ class NostrClient: ) timeout = timedelta(seconds=10) events = (await self.client.fetch_events(f, timeout)).to_vec() + events.sort( + key=lambda ev: getattr(ev, "created_at", getattr(ev, "timestamp", 0)) + ) deltas: list[bytes] = [] for ev in events: deltas.append(base64.b64decode(ev.content().encode("utf-8"))) diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index d1cf5f3d..bd844f63 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -1175,17 +1175,26 @@ class PasswordManager: return manifest, chunks = result encrypted = gzip.decompress(b"".join(chunks)) - if manifest.delta_since: - version = int(manifest.delta_since) - deltas = await self.nostr_client.fetch_deltas_since(version) - if deltas: - encrypted = deltas[-1] current = self.vault.get_encrypted_index() + updated = False if current != encrypted: if self.vault.decrypt_and_save_index_from_nostr( encrypted, strict=False ): - logger.info("Local database synchronized from Nostr.") + updated = True + current = encrypted + if manifest.delta_since: + version = int(manifest.delta_since) + deltas = await self.nostr_client.fetch_deltas_since(version) + for delta in deltas: + if current != delta: + if self.vault.decrypt_and_save_index_from_nostr( + delta, strict=False + ): + updated = True + current = delta + if updated: + logger.info("Local database synchronized from Nostr.") except Exception as e: logger.warning( "Unable to sync index from Nostr relays %s: %s", @@ -1304,17 +1313,22 @@ class PasswordManager: if result: manifest, chunks = result encrypted = gzip.decompress(b"".join(chunks)) - if manifest.delta_since: - version = int(manifest.delta_since) - deltas = await self.nostr_client.fetch_deltas_since(version) - if deltas: - encrypted = deltas[-1] success = self.vault.decrypt_and_save_index_from_nostr( encrypted, strict=False ) if success: - logger.info("Initialized local database from Nostr.") have_data = True + current = encrypted + if manifest.delta_since: + version = int(manifest.delta_since) + deltas = await self.nostr_client.fetch_deltas_since(version) + for delta in deltas: + if current != delta: + if self.vault.decrypt_and_save_index_from_nostr( + delta, strict=False + ): + current = delta + logger.info("Initialized local database from Nostr.") except Exception as e: # pragma: no cover - network errors logger.warning(f"Unable to sync index from Nostr: {e}") finally: diff --git a/src/tests/test_multiple_deltas_sync.py b/src/tests/test_multiple_deltas_sync.py new file mode 100644 index 00000000..3b2b2f71 --- /dev/null +++ b/src/tests/test_multiple_deltas_sync.py @@ -0,0 +1,96 @@ +import asyncio +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import create_vault, dummy_nostr_client + +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode + + +def _init_pm(dir_path: Path, client) -> PasswordManager: + vault, enc_mgr = create_vault(dir_path) + cfg_mgr = ConfigManager(vault, dir_path) + backup_mgr = BackupManager(dir_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.config_manager = cfg_mgr + pm.nostr_client = client + pm.fingerprint_dir = dir_path + pm.is_dirty = False + return pm + + +def test_sync_applies_multiple_deltas(dummy_nostr_client): + client, relay = dummy_nostr_client + with TemporaryDirectory() as tmpdir: + base = Path(tmpdir) + dir_a = base / "A" + dir_b = base / "B" + dir_a.mkdir() + dir_b.mkdir() + + pm_a = _init_pm(dir_a, client) + pm_b = _init_pm(dir_b, client) + + # Initial snapshot from manager A + pm_a.entry_manager.add_entry("site1", 12) + pm_a.sync_vault() + manifest_id = relay.manifests[-1].tags[0] + + # Manager B downloads snapshot + assert pm_b.attempt_initial_sync() is True + + # Two deltas published sequentially + pm_a.entry_manager.add_entry("site2", 12) + delta1 = pm_a.vault.get_encrypted_index() or b"" + asyncio.run(client.publish_delta(delta1, manifest_id)) + + pm_a.entry_manager.add_entry("site3", 12) + delta2 = pm_a.vault.get_encrypted_index() or b"" + asyncio.run(client.publish_delta(delta2, manifest_id)) + + # B syncs and should apply both deltas + pm_b.sync_index_from_nostr() + pm_b.entry_manager.clear_cache() + labels = [e[1] for e in pm_b.entry_manager.list_entries()] + assert sorted(labels) == ["site1", "site2", "site3"] + + +def test_initial_sync_applies_multiple_deltas(dummy_nostr_client): + client, relay = dummy_nostr_client + with TemporaryDirectory() as tmpdir: + base = Path(tmpdir) + dir_a = base / "A" + dir_b = base / "B" + dir_a.mkdir() + dir_b.mkdir() + + pm_a = _init_pm(dir_a, client) + pm_b = _init_pm(dir_b, client) + + pm_a.entry_manager.add_entry("site1", 12) + pm_a.sync_vault() + manifest_id = relay.manifests[-1].tags[0] + + pm_a.entry_manager.add_entry("site2", 12) + delta1 = pm_a.vault.get_encrypted_index() or b"" + asyncio.run(client.publish_delta(delta1, manifest_id)) + + pm_a.entry_manager.add_entry("site3", 12) + delta2 = pm_a.vault.get_encrypted_index() or b"" + asyncio.run(client.publish_delta(delta2, manifest_id)) + + # Initial sync after both deltas published + assert pm_b.attempt_initial_sync() is True + pm_b.entry_manager.clear_cache() + labels = [e[1] for e in pm_b.entry_manager.list_entries()] + assert sorted(labels) == ["site1", "site2", "site3"] From cb1e18c8bab0b58a847fe897448f960001dfaed5 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 20:05:30 -0400 Subject: [PATCH 71/75] Add manifest consistency check and tests --- src/nostr/client.py | 30 +++++++++++++++++++ src/tests/test_nostr_dummy_client.py | 44 +++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/nostr/client.py b/src/nostr/client.py index c04f89ab..01556177 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -368,6 +368,7 @@ class NostrClient: start = time.perf_counter() if self.offline_mode or not self.relays: return Manifest(ver=1, algo="gzip", chunks=[]), "" + await self.ensure_manifest_is_current() await self._connect_async() manifest, chunks = prepare_snapshot(encrypted_bytes, limit) for meta, chunk in zip(manifest.chunks, chunks): @@ -537,10 +538,39 @@ class NostrClient: return None + async def ensure_manifest_is_current(self) -> None: + """Verify the local manifest is up to date before publishing.""" + if self.offline_mode or not self.relays: + return + await self._connect_async() + pubkey = self.keys.public_key() + ident = f"{MANIFEST_ID_PREFIX}{self.fingerprint}" + f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).identifier(ident).limit(1) + timeout = timedelta(seconds=10) + try: + events = (await self.client.fetch_events(f, timeout)).to_vec() + except Exception: + return + if not events: + return + try: + data = json.loads(events[0].content()) + remote = data.get("delta_since") + if remote is not None: + remote = int(remote) + except Exception: + return + with self._state_lock: + local = self.current_manifest.delta_since if self.current_manifest else None + if remote is not None and (local is None or remote > local): + self.last_error = "Manifest out of date" + raise RuntimeError("Manifest out of date") + async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str: """Publish a delta event referencing a manifest.""" if self.offline_mode or not self.relays: return "" + await self.ensure_manifest_is_current() await self._connect_async() content = base64.b64encode(delta_bytes).decode("utf-8") diff --git a/src/tests/test_nostr_dummy_client.py b/src/tests/test_nostr_dummy_client.py index 6b242372..763e5b0d 100644 --- a/src/tests/test_nostr_dummy_client.py +++ b/src/tests/test_nostr_dummy_client.py @@ -1,8 +1,9 @@ import asyncio import gzip import math +import pytest -from helpers import create_vault, dummy_nostr_client +from helpers import create_vault, dummy_nostr_client, TEST_SEED from seedpass.core.entry_management import EntryManager from seedpass.core.backup import BackupManager from seedpass.core.config_manager import ConfigManager @@ -141,3 +142,44 @@ def test_fetch_snapshot_uses_event_ids(dummy_nostr_client): if getattr(f, "kind_val", None) == KIND_SNAPSHOT_CHUNK ] assert id_filters and all(id_filters) + + +def test_publish_delta_aborts_if_outdated(tmp_path, monkeypatch, dummy_nostr_client): + client1, relay = dummy_nostr_client + + from cryptography.fernet import Fernet + from nostr.client import NostrClient + from seedpass.core.encryption import EncryptionManager + + enc_mgr = EncryptionManager(Fernet.generate_key(), tmp_path) + + class DummyKeys: + def private_key_hex(self): + return "1" * 64 + + def public_key_hex(self): + return "2" * 64 + + class DummyKeyManager: + def __init__(self, *a, **k): + self.keys = DummyKeys() + + with pytest.MonkeyPatch().context() as mp: + mp.setattr("nostr.client.KeyManager", DummyKeyManager) + mp.setattr(enc_mgr, "decrypt_parent_seed", lambda: TEST_SEED) + client2 = NostrClient(enc_mgr, "fp") + + base = b"base" + manifest, _ = asyncio.run(client1.publish_snapshot(base)) + with client1._state_lock: + client1.current_manifest.delta_since = 0 + import copy + + with client2._state_lock: + client2.current_manifest = copy.deepcopy(manifest) + client2.current_manifest_id = manifest_id = relay.manifests[-1].tags[0] + + asyncio.run(client2.publish_delta(b"d1", manifest_id)) + + with pytest.raises(RuntimeError): + asyncio.run(client1.publish_delta(b"d2", manifest_id)) From 747e2be5a903beccd6a1311bbf608185b463a08d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 20:28:21 -0400 Subject: [PATCH 72/75] Add modified_ts tracking and merge logic --- src/seedpass/core/encryption.py | 15 ++++++++ src/seedpass/core/entry_management.py | 16 ++++++++- src/seedpass/core/manager.py | 10 +++--- src/seedpass/core/vault.py | 6 ++-- src/tests/test_delta_merge.py | 49 +++++++++++++++++++++++++++ src/tests/test_entry_add.py | 4 ++- 6 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 src/tests/test_delta_merge.py diff --git a/src/seedpass/core/encryption.py b/src/seedpass/core/encryption.py index d063387a..1a71ceda 100644 --- a/src/seedpass/core/encryption.py +++ b/src/seedpass/core/encryption.py @@ -228,6 +228,7 @@ class EncryptionManager: relative_path: Optional[Path] = None, *, strict: bool = True, + merge: bool = False, ) -> bool: """Decrypts data from Nostr and saves it. @@ -249,6 +250,20 @@ class EncryptionManager: data = json_lib.loads(decrypted_data) else: data = json_lib.loads(decrypted_data.decode("utf-8")) + if merge and (self.fingerprint_dir / relative_path).exists(): + current = self.load_json_data(relative_path) + current_entries = current.get("entries", {}) + for idx, entry in data.get("entries", {}).items(): + cur_ts = current_entries.get(idx, {}).get("modified_ts", 0) + new_ts = entry.get("modified_ts", 0) + if idx not in current_entries or new_ts >= cur_ts: + current_entries[idx] = entry + current["entries"] = current_entries + if "schema_version" in data: + current["schema_version"] = max( + current.get("schema_version", 0), data.get("schema_version", 0) + ) + data = current self.save_json_data(data, relative_path) # This always saves in V2 format self.update_checksum(relative_path) logger.info("Index file from Nostr was processed and saved successfully.") diff --git a/src/seedpass/core/entry_management.py b/src/seedpass/core/entry_management.py index 406cfa86..260fe17d 100644 --- a/src/seedpass/core/entry_management.py +++ b/src/seedpass/core/entry_management.py @@ -27,6 +27,7 @@ import logging import hashlib import sys import shutil +import time from typing import Optional, Tuple, Dict, Any, List from pathlib import Path @@ -97,6 +98,7 @@ class EntryManager: entry["word_count"] = entry["words"] entry.pop("words", None) entry.setdefault("tags", []) + entry.setdefault("modified_ts", entry.get("updated", 0)) logger.debug("Index loaded successfully.") self._index_cache = data return data @@ -176,6 +178,7 @@ class EntryManager: "type": EntryType.PASSWORD.value, "kind": EntryType.PASSWORD.value, "notes": notes, + "modified_ts": int(time.time()), "custom_fields": custom_fields or [], "tags": tags or [], } @@ -236,6 +239,7 @@ class EntryManager: "type": EntryType.TOTP.value, "kind": EntryType.TOTP.value, "label": label, + "modified_ts": int(time.time()), "index": index, "period": period, "digits": digits, @@ -249,6 +253,7 @@ class EntryManager: "kind": EntryType.TOTP.value, "label": label, "secret": secret, + "modified_ts": int(time.time()), "period": period, "digits": digits, "archived": archived, @@ -294,6 +299,7 @@ class EntryManager: "kind": EntryType.SSH.value, "index": index, "label": label, + "modified_ts": int(time.time()), "notes": notes, "archived": archived, "tags": tags or [], @@ -340,6 +346,7 @@ class EntryManager: "kind": EntryType.PGP.value, "index": index, "label": label, + "modified_ts": int(time.time()), "key_type": key_type, "user_id": user_id, "notes": notes, @@ -392,6 +399,7 @@ class EntryManager: "kind": EntryType.NOSTR.value, "index": index, "label": label, + "modified_ts": int(time.time()), "notes": notes, "archived": archived, "tags": tags or [], @@ -421,6 +429,7 @@ class EntryManager: "type": EntryType.KEY_VALUE.value, "kind": EntryType.KEY_VALUE.value, "label": label, + "modified_ts": int(time.time()), "value": value, "notes": notes, "archived": archived, @@ -480,6 +489,7 @@ class EntryManager: "kind": EntryType.SEED.value, "index": index, "label": label, + "modified_ts": int(time.time()), "word_count": words_num, "notes": notes, "archived": archived, @@ -552,6 +562,7 @@ class EntryManager: "kind": EntryType.MANAGED_ACCOUNT.value, "index": index, "label": label, + "modified_ts": int(time.time()), "word_count": word_count, "notes": notes, "fingerprint": fingerprint, @@ -682,7 +693,8 @@ class EntryManager: ): entry.setdefault("custom_fields", []) logger.debug(f"Retrieved entry at index {index}: {entry}") - return entry + clean = {k: v for k, v in entry.items() if k != "modified_ts"} + return clean else: logger.warning(f"No entry found at index {index}.") print(colored(f"Warning: No entry found at index {index}.", "yellow")) @@ -887,6 +899,8 @@ class EntryManager: entry["tags"] = tags logger.debug(f"Updated tags for index {index}: {tags}") + entry["modified_ts"] = int(time.time()) + data["entries"][str(index)] = entry logger.debug(f"Modified entry at index {index}: {entry}") diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index bd844f63..ec7d8d39 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -1179,7 +1179,7 @@ class PasswordManager: updated = False if current != encrypted: if self.vault.decrypt_and_save_index_from_nostr( - encrypted, strict=False + encrypted, strict=False, merge=False ): updated = True current = encrypted @@ -1189,7 +1189,7 @@ class PasswordManager: for delta in deltas: if current != delta: if self.vault.decrypt_and_save_index_from_nostr( - delta, strict=False + delta, strict=False, merge=True ): updated = True current = delta @@ -1314,7 +1314,7 @@ class PasswordManager: manifest, chunks = result encrypted = gzip.decompress(b"".join(chunks)) success = self.vault.decrypt_and_save_index_from_nostr( - encrypted, strict=False + encrypted, strict=False, merge=False ) if success: have_data = True @@ -1325,7 +1325,7 @@ class PasswordManager: for delta in deltas: if current != delta: if self.vault.decrypt_and_save_index_from_nostr( - delta, strict=False + delta, strict=False, merge=True ): current = delta logger.info("Initialized local database from Nostr.") @@ -3641,7 +3641,7 @@ class PasswordManager: :param encrypted_data: The encrypted data retrieved from Nostr. """ try: - self.vault.decrypt_and_save_index_from_nostr(encrypted_data) + self.vault.decrypt_and_save_index_from_nostr(encrypted_data, merge=True) logging.info("Index file updated from Nostr successfully.") print(colored("Index file updated from Nostr successfully.", "green")) except Exception as e: diff --git a/src/seedpass/core/vault.py b/src/seedpass/core/vault.py index 93667c18..e1a6fbc3 100644 --- a/src/seedpass/core/vault.py +++ b/src/seedpass/core/vault.py @@ -61,11 +61,11 @@ class Vault: return self.encryption_manager.get_encrypted_index() def decrypt_and_save_index_from_nostr( - self, encrypted_data: bytes, *, strict: bool = True + self, encrypted_data: bytes, *, strict: bool = True, merge: bool = False ) -> bool: - """Decrypt Nostr payload and overwrite the local index.""" + """Decrypt Nostr payload and update the local index.""" return self.encryption_manager.decrypt_and_save_index_from_nostr( - encrypted_data, strict=strict + encrypted_data, strict=strict, merge=merge ) # ----- Config helpers ----- diff --git a/src/tests/test_delta_merge.py b/src/tests/test_delta_merge.py new file mode 100644 index 00000000..c6b28663 --- /dev/null +++ b/src/tests/test_delta_merge.py @@ -0,0 +1,49 @@ +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest + +from helpers import create_vault +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager + + +def _setup_mgr(path: Path): + vault, _ = create_vault(path) + cfg = ConfigManager(vault, path) + backup = BackupManager(path, cfg) + return vault, EntryManager(vault, backup) + + +def test_merge_modified_ts(): + with TemporaryDirectory() as tmpdir: + base = Path(tmpdir) + va, ema = _setup_mgr(base / "A") + vb, emb = _setup_mgr(base / "B") + + idx0 = ema.add_entry("a", 8) + idx1 = ema.add_entry("b", 8) + + # B starts from A's snapshot + enc = va.get_encrypted_index() or b"" + vb.decrypt_and_save_index_from_nostr(enc, merge=False) + emb.clear_cache() + assert emb.retrieve_entry(idx0)["username"] == "" + + ema.modify_entry(idx0, username="ua") + delta_a = va.get_encrypted_index() or b"" + vb.decrypt_and_save_index_from_nostr(delta_a, merge=True) + emb.clear_cache() + assert emb.retrieve_entry(idx0)["username"] == "ua" + + emb.modify_entry(idx1, username="ub") + delta_b = vb.get_encrypted_index() or b"" + va.decrypt_and_save_index_from_nostr(delta_b, merge=True) + ema.clear_cache() + assert ema.retrieve_entry(idx1)["username"] == "ub" + + assert ema.retrieve_entry(idx0)["username"] == "ua" + assert ema.retrieve_entry(idx1)["username"] == "ub" + assert emb.retrieve_entry(idx0)["username"] == "ua" + assert emb.retrieve_entry(idx1)["username"] == "ub" diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index 07344bbf..c7a966a5 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -44,7 +44,9 @@ def test_add_and_retrieve_entry(): data = enc_mgr.load_json_data(entry_mgr.index_file) assert str(index) in data.get("entries", {}) - assert data["entries"][str(index)] == entry + stored = data["entries"][str(index)] + stored.pop("modified_ts", None) + assert stored == entry @pytest.mark.parametrize( From ea2451f4a06286124583b9df1a269eabc88bd61a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 21:04:04 -0400 Subject: [PATCH 73/75] Persist manifest state --- src/seedpass/core/manager.py | 47 ++++++++++++++++ src/seedpass/core/state_manager.py | 6 ++ src/tests/test_manifest_state_restore.py | 70 ++++++++++++++++++++++++ src/tests/test_state_manager.py | 11 +++- 4 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 src/tests/test_manifest_state_restore.py diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index ec7d8d39..0af2fb83 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -644,6 +644,17 @@ class PasswordManager: config_manager=getattr(self, "config_manager", None), parent_seed=getattr(self, "parent_seed", None), ) + if getattr(self, "manifest_id", None): + from nostr.backup_models import Manifest + + with self.nostr_client._state_lock: + self.nostr_client.current_manifest_id = self.manifest_id + self.nostr_client.current_manifest = Manifest( + ver=1, + algo="gzip", + chunks=[], + delta_since=self.delta_since or None, + ) logging.info( f"NostrClient re-initialized with seed profile {self.current_fingerprint}." ) @@ -1127,10 +1138,14 @@ class PasswordManager: relay_list = state.get("relays", list(DEFAULT_RELAYS)) self.last_bip85_idx = state.get("last_bip85_idx", 0) self.last_sync_ts = state.get("last_sync_ts", 0) + self.manifest_id = state.get("manifest_id") + self.delta_since = state.get("delta_since", 0) else: relay_list = list(DEFAULT_RELAYS) self.last_bip85_idx = 0 self.last_sync_ts = 0 + self.manifest_id = None + self.delta_since = 0 self.offline_mode = bool(config.get("offline_mode", False)) self.inactivity_timeout = config.get( "inactivity_timeout", INACTIVITY_TIMEOUT @@ -1149,6 +1164,18 @@ class PasswordManager: parent_seed=getattr(self, "parent_seed", None), ) + if getattr(self, "manifest_id", None): + from nostr.backup_models import Manifest + + with self.nostr_client._state_lock: + self.nostr_client.current_manifest_id = self.manifest_id + self.nostr_client.current_manifest = Manifest( + ver=1, + algo="gzip", + chunks=[], + delta_since=self.delta_since or None, + ) + logger.debug("Managers re-initialized for the new fingerprint.") except Exception as e: @@ -3684,6 +3711,14 @@ class PasswordManager: if manifest is not None: chunk_ids = [c.event_id for c in manifest.chunks if c.event_id] delta_ids = self.nostr_client.get_delta_events() + if manifest is not None and self.state_manager is not None: + ts = manifest.delta_since or int(time.time()) + self.state_manager.update_state( + manifest_id=event_id, + delta_since=ts, + last_sync_ts=ts, + ) + self.last_sync_ts = ts return { "manifest_id": event_id, "chunk_ids": chunk_ids, @@ -4062,6 +4097,18 @@ class PasswordManager: parent_seed=getattr(self, "parent_seed", None), ) + if getattr(self, "manifest_id", None): + from nostr.backup_models import Manifest + + with self.nostr_client._state_lock: + self.nostr_client.current_manifest_id = self.manifest_id + self.nostr_client.current_manifest = Manifest( + ver=1, + algo="gzip", + chunks=[], + delta_since=self.delta_since or None, + ) + # Push a fresh backup to Nostr so the newly encrypted index is # stored remotely. Include a tag to mark the password change. try: diff --git a/src/seedpass/core/state_manager.py b/src/seedpass/core/state_manager.py index 8d142f92..f2ca11e6 100644 --- a/src/seedpass/core/state_manager.py +++ b/src/seedpass/core/state_manager.py @@ -23,6 +23,8 @@ class StateManager: return { "last_bip85_idx": 0, "last_sync_ts": 0, + "manifest_id": None, + "delta_since": 0, "relays": list(DEFAULT_RELAYS), } with shared_lock(self.state_path) as fh: @@ -32,6 +34,8 @@ class StateManager: return { "last_bip85_idx": 0, "last_sync_ts": 0, + "manifest_id": None, + "delta_since": 0, "relays": list(DEFAULT_RELAYS), } try: @@ -40,6 +44,8 @@ class StateManager: obj = {} obj.setdefault("last_bip85_idx", 0) obj.setdefault("last_sync_ts", 0) + obj.setdefault("manifest_id", None) + obj.setdefault("delta_since", 0) obj.setdefault("relays", list(DEFAULT_RELAYS)) return obj diff --git a/src/tests/test_manifest_state_restore.py b/src/tests/test_manifest_state_restore.py new file mode 100644 index 00000000..c28fad78 --- /dev/null +++ b/src/tests/test_manifest_state_restore.py @@ -0,0 +1,70 @@ +import asyncio +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import create_vault, dummy_nostr_client, TEST_SEED + +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.state_manager import StateManager +from seedpass.core.manager import PasswordManager, EncryptionMode + + +def _init_pm(dir_path: Path, client) -> PasswordManager: + vault, enc_mgr = create_vault(dir_path) + cfg_mgr = ConfigManager(vault, dir_path) + backup_mgr = BackupManager(dir_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + state_mgr = StateManager(dir_path) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.config_manager = cfg_mgr + pm.state_manager = state_mgr + pm.nostr_client = client + pm.fingerprint_dir = dir_path + pm.current_fingerprint = "fp" + pm.parent_seed = TEST_SEED + pm.is_dirty = False + return pm + + +def test_manifest_state_restored(monkeypatch, tmp_path): + client, relay = dummy_nostr_client.__wrapped__(tmp_path / "c1", monkeypatch) + with TemporaryDirectory() as tmpdir: + fp_dir = Path(tmpdir) + pm1 = _init_pm(fp_dir, client) + pm1.entry_manager.add_entry("site", 8) + result = pm1.sync_vault() + manifest_id = relay.manifests[-1].tags[0] + state = pm1.state_manager.state + delta_ts = state["delta_since"] + assert state["manifest_id"] == manifest_id + assert delta_ts > 0 + assert result["manifest_id"] == manifest_id + + client2, _ = dummy_nostr_client.__wrapped__(tmp_path / "c2", monkeypatch) + monkeypatch.setattr( + "seedpass.core.manager.NostrClient", lambda *a, **k: client2 + ) + + pm2 = PasswordManager.__new__(PasswordManager) + pm2.encryption_mode = EncryptionMode.SEED_ONLY + vault2, enc_mgr2 = create_vault(fp_dir) + pm2.encryption_manager = enc_mgr2 + pm2.vault = vault2 + pm2.fingerprint_dir = fp_dir + pm2.current_fingerprint = "fp" + pm2.parent_seed = TEST_SEED + pm2.bip85 = None + pm2.initialize_managers() + + assert pm2.nostr_client is client2 + assert pm2.nostr_client.get_current_manifest_id() == manifest_id + assert pm2.nostr_client.get_current_manifest().delta_since == delta_ts + assert pm2.last_sync_ts == delta_ts diff --git a/src/tests/test_state_manager.py b/src/tests/test_state_manager.py index 0aef6d65..71abe250 100644 --- a/src/tests/test_state_manager.py +++ b/src/tests/test_state_manager.py @@ -12,15 +12,24 @@ def test_state_manager_round_trip(): assert state["relays"] == list(DEFAULT_RELAYS) assert state["last_bip85_idx"] == 0 assert state["last_sync_ts"] == 0 + assert state["manifest_id"] is None + assert state["delta_since"] == 0 sm.add_relay("wss://example.com") - sm.update_state(last_bip85_idx=5, last_sync_ts=123) + sm.update_state( + last_bip85_idx=5, + last_sync_ts=123, + manifest_id="mid", + delta_since=111, + ) sm2 = StateManager(Path(tmpdir)) state2 = sm2.state assert "wss://example.com" in state2["relays"] assert state2["last_bip85_idx"] == 5 assert state2["last_sync_ts"] == 123 + assert state2["manifest_id"] == "mid" + assert state2["delta_since"] == 111 sm2.remove_relay(1) # remove first default relay assert len(sm2.list_relays()) == len(DEFAULT_RELAYS) From 0edf4e5c832d686448183ea66a34a5e51ed371a0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 21:47:04 -0400 Subject: [PATCH 74/75] test: add race condition sync test --- src/tests/test_sync_race_conditions.py | 98 ++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/tests/test_sync_race_conditions.py diff --git a/src/tests/test_sync_race_conditions.py b/src/tests/test_sync_race_conditions.py new file mode 100644 index 00000000..07866594 --- /dev/null +++ b/src/tests/test_sync_race_conditions.py @@ -0,0 +1,98 @@ +import asyncio +import threading +from pathlib import Path + + +from helpers import create_vault, dummy_nostr_client + +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode + + +def _init_pm(dir_path: Path, client) -> PasswordManager: + vault, enc_mgr = create_vault(dir_path) + cfg_mgr = ConfigManager(vault, dir_path) + backup_mgr = BackupManager(dir_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.config_manager = cfg_mgr + pm.nostr_client = client + pm.fingerprint_dir = dir_path + pm.is_dirty = False + pm.state_manager = None + return pm + + +def test_sync_race_conditions(monkeypatch, tmp_path): + client_a, relay = dummy_nostr_client.__wrapped__(tmp_path / "c1", monkeypatch) + + from cryptography.fernet import Fernet + from nostr.client import NostrClient + from seedpass.core.encryption import EncryptionManager + from helpers import TEST_SEED + + enc_mgr = EncryptionManager(Fernet.generate_key(), tmp_path / "c2") + + class DummyKeys: + def private_key_hex(self): + return "1" * 64 + + def public_key_hex(self): + return "2" * 64 + + class DummyKeyManager: + def __init__(self, *a, **k): + self.keys = DummyKeys() + + monkeypatch.setattr("nostr.client.KeyManager", DummyKeyManager) + monkeypatch.setattr(enc_mgr, "decrypt_parent_seed", lambda: TEST_SEED) + client_b = NostrClient(enc_mgr, "fp") + + dir_a = tmp_path / "A" + dir_b = tmp_path / "B" + dir_a.mkdir() + dir_b.mkdir() + + pm_a = _init_pm(dir_a, client_a) + pm_b = _init_pm(dir_b, client_b) + + pm_a.entry_manager.add_entry("init", 12) + pm_a.sync_vault() + manifest_id = relay.manifests[-1].tags[0] + assert pm_b.attempt_initial_sync() is True + + pm_b.entry_manager.get_next_index = lambda: 2 + + def publish(pm: PasswordManager, client, label: str) -> None: + pm.entry_manager.add_entry(label, 12) + data = pm.vault.get_encrypted_index() or b"" + try: + asyncio.run(client.publish_delta(data, manifest_id)) + except RuntimeError: + pm.sync_index_from_nostr() + pm.entry_manager.clear_cache() + pm.entry_manager.add_entry(label, 12) + data = pm.vault.get_encrypted_index() or b"" + asyncio.run(client.publish_delta(data, manifest_id)) + + t1 = threading.Thread(target=publish, args=(pm_a, client_a, "from_a")) + t2 = threading.Thread(target=publish, args=(pm_b, client_b, "from_b")) + t1.start() + t2.start() + t1.join() + t2.join() + + assert len(relay.deltas) >= 1 + + pm_b.sync_index_from_nostr() + pm_b.entry_manager.clear_cache() + labels = [e[1] for e in pm_b.entry_manager.list_entries()] + assert "from_a" in labels and "from_b" in labels From 5b0051f76f8fe23e7779e135e16a30e96e4222df Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 25 Jul 2025 19:08:06 -0400 Subject: [PATCH 75/75] docs: clarify beta installer command --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 11c0af40..902914e5 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/ ```bash bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)" _ -b beta ``` +Make sure the command ends right after `-b beta` with **no trailing parenthesis**. **Windows (PowerShell):** ```powershell