diff --git a/README.md b/README.md index a9d620c..5ecc86f 100644 --- a/README.md +++ b/README.md @@ -417,6 +417,8 @@ You can adjust these settings directly from the command line: seedpass config set kdf_iterations 200000 seedpass config set backup_interval 3600 seedpass config set quick_unlock true +seedpass config set nostr_max_retries 2 +seedpass config set nostr_retry_delay 1 ``` The default configuration uses **50,000** PBKDF2 iterations. Lower iteration counts speed up vault decryption but make brute-force attacks easier. A long backup interval means fewer backups and increases the risk of data loss. 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 6644a02..7fa3768 100644 --- a/docs/docs/content/01-getting-started/01-advanced_cli.md +++ b/docs/docs/content/01-getting-started/01-advanced_cli.md @@ -172,8 +172,8 @@ Code: 123456 ### `config` Commands -- **`seedpass config get `** – Retrieve a configuration value such as `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `clipboard_clear_delay`, `additional_backup_path`, `relays`, `quick_unlock`, or password policy fields like `min_uppercase`. -- **`seedpass config set `** – Update a configuration option. Example: `seedpass config set kdf_iterations 200000`. Use keys like `min_uppercase`, `min_lowercase`, `min_digits`, `min_special`, or `quick_unlock` to adjust settings. +- **`seedpass config get `** – Retrieve a configuration value such as `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `clipboard_clear_delay`, `additional_backup_path`, `relays`, `quick_unlock`, `nostr_max_retries`, `nostr_retry_delay`, or password policy fields like `min_uppercase`. +- **`seedpass config set `** – Update a configuration option. Example: `seedpass config set kdf_iterations 200000`. Use keys like `min_uppercase`, `min_lowercase`, `min_digits`, `min_special`, `nostr_max_retries`, `nostr_retry_delay`, or `quick_unlock` to adjust settings. - **`seedpass config toggle-secret-mode`** – Interactively enable or disable Secret Mode and set the clipboard delay. - **`seedpass config toggle-offline`** – Enable or disable offline mode to skip Nostr operations. @@ -210,6 +210,6 @@ Shut down the server with `seedpass api stop`. - Use the `--help` flag for details on any command. - Set a strong master password and regularly export encrypted backups. -- Adjust configuration values like `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, or `quick_unlock` through the `config` commands. +- Adjust configuration values like `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `nostr_max_retries`, `nostr_retry_delay`, or `quick_unlock` through the `config` commands. - Customize password complexity with `config set min_uppercase 3`, `config set min_digits 4`, and similar commands. - `entry get` is script‑friendly and can be piped into other commands. diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py index 3b92f8b..2837227 100644 --- a/scripts/generate_test_profile.py +++ b/scripts/generate_test_profile.py @@ -46,7 +46,9 @@ import gzip DEFAULT_PASSWORD = "testpassword" -def initialize_profile(profile_name: str) -> tuple[str, EntryManager, Path, str]: +def initialize_profile( + profile_name: str, +) -> tuple[str, EntryManager, Path, str, ConfigManager]: """Create or load a profile and return the seed phrase, manager, directory and fingerprint.""" initialize_app() seed_txt = APP_DIR / f"{profile_name}_seed.txt" @@ -98,7 +100,7 @@ def initialize_profile(profile_name: str) -> tuple[str, EntryManager, Path, str] cfg_mgr.set_password_hash(hashed) backup_mgr = BackupManager(profile_dir, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) - return seed_phrase, entry_mgr, profile_dir, fingerprint + return seed_phrase, entry_mgr, profile_dir, fingerprint, cfg_mgr def random_secret(length: int = 16) -> str: @@ -159,7 +161,7 @@ def main() -> None: ) args = parser.parse_args() - seed, entry_mgr, dir_path, fingerprint = initialize_profile(args.profile) + seed, entry_mgr, dir_path, fingerprint, cfg_mgr = initialize_profile(args.profile) print(f"Using profile directory: {dir_path}") print(f"Parent seed: {seed}") if fingerprint: @@ -173,6 +175,7 @@ def main() -> None: entry_mgr.vault.encryption_manager, fingerprint or dir_path.name, parent_seed=seed, + config_manager=cfg_mgr, ) asyncio.run(client.publish_snapshot(encrypted)) print("[+] Data synchronized to Nostr.") diff --git a/src/constants.py b/src/constants.py index dfcd0d1..2ebdf36 100644 --- a/src/constants.py +++ b/src/constants.py @@ -9,8 +9,9 @@ logger = logging.getLogger(__name__) # ----------------------------------- # Nostr Relay Connection Settings # ----------------------------------- -MAX_RETRIES = 3 # Maximum number of retries for relay connections -RETRY_DELAY = 5 # Seconds to wait before retrying a failed connection +# 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 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 1268f73..b37d6de 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -4,7 +4,7 @@ import base64 import json import logging import time -from typing import List, Optional, Tuple +from typing import List, Optional, Tuple, TYPE_CHECKING import hashlib import asyncio import gzip @@ -27,8 +27,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 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 + # Backwards compatibility for tests that patch these symbols KeyManager = SeedPassKeyManager ClientBuilder = Client @@ -91,10 +95,12 @@ class NostrClient: relays: Optional[List[str]] = None, parent_seed: Optional[str] = None, offline_mode: bool = False, + config_manager: Optional["ConfigManager"] = None, ) -> None: self.encryption_manager = encryption_manager self.fingerprint = fingerprint self.fingerprint_dir = self.encryption_manager.fingerprint_dir + self.config_manager = config_manager if parent_seed is None: parent_seed = self.encryption_manager.decrypt_parent_seed() @@ -270,11 +276,27 @@ class NostrClient: self._connected = False def retrieve_json_from_nostr_sync( - self, retries: int = 0, delay: float = 2.0 + self, retries: int | None = None, delay: float | None = None ) -> Optional[bytes]: """Retrieve the latest Kind 1 event from the author with optional retries.""" if self.offline_mode or not self.relays: return None + + 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 + + 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) + retries = int(cfg.get("nostr_max_retries", MAX_RETRIES)) + delay = float(cfg.get("nostr_retry_delay", RETRY_DELAY)) + self.connect() self.last_error = None attempt = 0 diff --git a/src/password_manager/config_manager.py b/src/password_manager/config_manager.py index e3c9d10..08c5988 100644 --- a/src/password_manager/config_manager.py +++ b/src/password_manager/config_manager.py @@ -52,6 +52,8 @@ class ConfigManager: "secret_mode_enabled": False, "clipboard_clear_delay": 45, "quick_unlock": False, + "nostr_max_retries": 2, + "nostr_retry_delay": 1.0, "min_uppercase": 2, "min_lowercase": 2, "min_digits": 2, @@ -74,6 +76,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("min_uppercase", 2) data.setdefault("min_lowercase", 2) data.setdefault("min_digits", 2) @@ -285,3 +289,29 @@ class ConfigManager: """Retrieve whether quick unlock is enabled.""" cfg = self.load_config(require_pin=False) return bool(cfg.get("quick_unlock", False)) + + def set_nostr_max_retries(self, retries: int) -> None: + """Persist the maximum number of Nostr retry attempts.""" + if retries < 0: + raise ValueError("retries cannot be negative") + cfg = self.load_config(require_pin=False) + cfg["nostr_max_retries"] = int(retries) + self.save_config(cfg) + + 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)) + + def set_nostr_retry_delay(self, delay: float) -> None: + """Persist the delay between Nostr retry attempts.""" + if delay < 0: + raise ValueError("delay cannot be negative") + cfg = self.load_config(require_pin=False) + cfg["nostr_retry_delay"] = float(delay) + self.save_config(cfg) + + 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)) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 548bb8e..50c7e17 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -544,6 +544,7 @@ class PasswordManager: self.nostr_client = NostrClient( encryption_manager=self.encryption_manager, fingerprint=self.current_fingerprint, + config_manager=getattr(self, "config_manager", None), parent_seed=getattr(self, "parent_seed", None), ) logging.info( @@ -1020,6 +1021,7 @@ class PasswordManager: fingerprint=self.current_fingerprint, relays=relay_list, offline_mode=self.offline_mode, + config_manager=self.config_manager, parent_seed=getattr(self, "parent_seed", None), ) @@ -3718,6 +3720,7 @@ class PasswordManager: encryption_manager=self.encryption_manager, fingerprint=self.current_fingerprint, relays=relay_list, + config_manager=self.config_manager, parent_seed=getattr(self, "parent_seed", None), ) diff --git a/src/password_manager/portable_backup.py b/src/password_manager/portable_backup.py index 3e27671..8731818 100644 --- a/src/password_manager/portable_backup.py +++ b/src/password_manager/portable_backup.py @@ -90,7 +90,11 @@ def export_backup( enc_file.write_bytes(encrypted) os.chmod(enc_file, 0o600) try: - client = NostrClient(vault.encryption_manager, vault.fingerprint_dir.name) + client = NostrClient( + vault.encryption_manager, + vault.fingerprint_dir.name, + config_manager=backup_manager.config_manager, + ) asyncio.run(client.publish_snapshot(encrypted)) except Exception: logger.error("Failed to publish backup via Nostr", exc_info=True) diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 6280749..1eb32d5 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -464,6 +464,8 @@ def config_set(ctx: typer.Context, key: str, value: str) -> None: "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)), diff --git a/src/tests/test_cli_config_set_extra.py b/src/tests/test_cli_config_set_extra.py index be4cae6..6c06b0c 100644 --- a/src/tests/test_cli_config_set_extra.py +++ b/src/tests/test_cli_config_set_extra.py @@ -18,6 +18,8 @@ runner = CliRunner() ("kdf_iterations", "123", "set_kdf_iterations", 123), ("kdf_mode", "argon2", "set_kdf_mode", "argon2"), ("quick_unlock", "true", "set_quick_unlock", True), + ("nostr_max_retries", "3", "set_nostr_max_retries", 3), + ("nostr_retry_delay", "1.5", "set_nostr_retry_delay", 1.5), ( "relays", "wss://a.com, wss://b.com", diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py index e9012d4..44bf430 100644 --- a/src/tests/test_cli_doc_examples.py +++ b/src/tests/test_cli_doc_examples.py @@ -63,6 +63,8 @@ class DummyPM: set_clipboard_clear_delay=lambda v: None, set_additional_backup_path=lambda v: None, set_relays=lambda v, require_pin=False: None, + set_nostr_max_retries=lambda v: None, + set_nostr_retry_delay=lambda v: None, set_offline_mode=lambda v: None, get_secret_mode_enabled=lambda: True, get_clipboard_clear_delay=lambda: 30, diff --git a/src/tests/test_config_manager.py b/src/tests/test_config_manager.py index 61016a3..d26e465 100644 --- a/src/tests/test_config_manager.py +++ b/src/tests/test_config_manager.py @@ -181,3 +181,18 @@ def test_quick_unlock_round_trip(): cfg_mgr.set_quick_unlock(True) assert cfg_mgr.get_quick_unlock() is True + + +def test_nostr_retry_settings_round_trip(): + with TemporaryDirectory() as tmpdir: + vault, _ = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, Path(tmpdir)) + + cfg = cfg_mgr.load_config(require_pin=False) + assert cfg["nostr_max_retries"] == 2 + assert cfg["nostr_retry_delay"] == 1.0 + + cfg_mgr.set_nostr_max_retries(5) + cfg_mgr.set_nostr_retry_delay(3.5) + assert cfg_mgr.get_nostr_max_retries() == 5 + assert cfg_mgr.get_nostr_retry_delay() == 3.5 diff --git a/src/tests/test_generate_test_profile.py b/src/tests/test_generate_test_profile.py index 6313968..8b6da8c 100644 --- a/src/tests/test_generate_test_profile.py +++ b/src/tests/test_generate_test_profile.py @@ -24,7 +24,8 @@ def test_initialize_profile_creates_directories(monkeypatch): assert spec.loader is not None spec.loader.exec_module(gtp) - seed, mgr, dir_path, fingerprint = gtp.initialize_profile("test") + seed, mgr, dir_path, fingerprint, cfg_mgr = gtp.initialize_profile("test") + assert cfg_mgr is not None assert constants.APP_DIR.exists() assert (constants.APP_DIR / "test_seed.txt").exists()