diff --git a/README.md b/README.md index 84d71d2..535523f 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - **Change Master Password:** Rotate your encryption password at any time. - **Checksum Verification Utilities:** Verify or regenerate the script checksum. - **Relay Management:** List, add, remove or reset configured Nostr relays. +- **Offline Mode:** Disable all Nostr communication for local-only operation. ## Prerequisites 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 7b71add..61375bf 100644 --- a/docs/docs/content/01-getting-started/01-advanced_cli.md +++ b/docs/docs/content/01-getting-started/01-advanced_cli.md @@ -93,6 +93,7 @@ Manage profile‑specific settings. | :--- | :--- | :--- | | Get a setting value | `config get` | `seedpass config get kdf_iterations` | | Set a setting value | `config set` | `seedpass config set backup_interval 3600` | +| Toggle offline mode | `config toggle-offline` | `seedpass config toggle-offline` | ### Fingerprint Commands @@ -174,6 +175,7 @@ Code: 123456 - **`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`, 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`, or `min_special` to adjust password complexity. - **`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. ### `fingerprint` Commands diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index 4f8e77f..1a748ff 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -60,6 +60,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - **Change Master Password:** Rotate your encryption password at any time. - **Checksum Verification Utilities:** Verify or regenerate the script checksum. - **Relay Management:** List, add, remove or reset configured Nostr relays. +- **Offline Mode:** Disable network sync to work entirely locally. ## Prerequisites diff --git a/src/nostr/client.py b/src/nostr/client.py index 4ebe15f..1268f73 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -90,6 +90,7 @@ class NostrClient: fingerprint: str, relays: Optional[List[str]] = None, parent_seed: Optional[str] = None, + offline_mode: bool = False, ) -> None: self.encryption_manager = encryption_manager self.fingerprint = fingerprint @@ -110,7 +111,11 @@ class NostrClient: except Exception: self.keys = Keys.generate() - self.relays = relays if relays else DEFAULT_RELAYS + self.offline_mode = offline_mode + if relays is None: + self.relays = [] if offline_mode else DEFAULT_RELAYS + else: + self.relays = relays # store the last error encountered during network operations self.last_error: Optional[str] = None @@ -127,19 +132,27 @@ class NostrClient: def connect(self) -> None: """Connect the client to all configured relays.""" + if self.offline_mode or not self.relays: + return if not self._connected: self.initialize_client_pool() def initialize_client_pool(self) -> None: """Add relays to the client and connect.""" + if self.offline_mode or not self.relays: + return asyncio.run(self._initialize_client_pool()) async def _connect_async(self) -> None: """Ensure the client is connected within an async context.""" + if self.offline_mode or not self.relays: + return if not self._connected: await self._initialize_client_pool() async def _initialize_client_pool(self) -> None: + if self.offline_mode or not self.relays: + return if hasattr(self.client, "add_relays"): await self.client.add_relays(self.relays) else: @@ -181,6 +194,8 @@ class NostrClient: def check_relay_health(self, min_relays: int = 2, timeout: float = 5.0) -> int: """Ping relays and return the count of those providing data.""" + if self.offline_mode or not self.relays: + return 0 return asyncio.run(self._check_relay_health(min_relays, timeout)) def publish_json_to_nostr( @@ -201,6 +216,8 @@ class NostrClient: If provided, include an ``alt`` tag so uploads can be associated with a specific event like a password change. """ + if self.offline_mode or not self.relays: + return None self.connect() self.last_error = None try: @@ -233,10 +250,14 @@ class NostrClient: def publish_event(self, event): """Publish a prepared event to the configured relays.""" + if self.offline_mode or not self.relays: + return None self.connect() return asyncio.run(self._publish_event(event)) async def _publish_event(self, event): + if self.offline_mode or not self.relays: + return None await self._connect_async() return await self.client.send_event(event) @@ -252,6 +273,8 @@ class NostrClient: self, retries: int = 0, delay: float = 2.0 ) -> Optional[bytes]: """Retrieve the latest Kind 1 event from the author with optional retries.""" + if self.offline_mode or not self.relays: + return None self.connect() self.last_error = None attempt = 0 @@ -270,6 +293,8 @@ class NostrClient: return None async def _retrieve_json_from_nostr(self) -> Optional[bytes]: + if self.offline_mode or not self.relays: + return None await self._connect_async() # Filter for the latest text note (Kind 1) from our public key pubkey = self.keys.public_key() @@ -304,6 +329,8 @@ class NostrClient: Maximum chunk size in bytes. Defaults to 50 kB. """ + if self.offline_mode or not self.relays: + return Manifest(ver=1, algo="gzip", chunks=[]), "" await self._connect_async() manifest, chunks = prepare_snapshot(encrypted_bytes, limit) for meta, chunk in zip(manifest.chunks, chunks): @@ -336,7 +363,8 @@ class NostrClient: async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None: """Retrieve the latest manifest and all snapshot chunks.""" - + if self.offline_mode or not self.relays: + return None await self._connect_async() pubkey = self.keys.public_key() @@ -376,7 +404,8 @@ class NostrClient: 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._connect_async() content = base64.b64encode(delta_bytes).decode("utf-8") @@ -392,7 +421,8 @@ class NostrClient: async def fetch_deltas_since(self, version: int) -> list[bytes]: """Retrieve delta events newer than the given version.""" - + if self.offline_mode or not self.relays: + return [] await self._connect_async() pubkey = self.keys.public_key() diff --git a/src/password_manager/config_manager.py b/src/password_manager/config_manager.py index 7486012..da02273 100644 --- a/src/password_manager/config_manager.py +++ b/src/password_manager/config_manager.py @@ -41,6 +41,7 @@ class ConfigManager: logger.info("Config file not found; returning defaults") return { "relays": list(DEFAULT_NOSTR_RELAYS), + "offline_mode": False, "pin_hash": "", "password_hash": "", "inactivity_timeout": INACTIVITY_TIMEOUT, @@ -61,6 +62,7 @@ class ConfigManager: raise ValueError("Config data must be a dictionary") # Ensure defaults for missing keys data.setdefault("relays", list(DEFAULT_NOSTR_RELAYS)) + data.setdefault("offline_mode", False) data.setdefault("pin_hash", "") data.setdefault("password_hash", "") data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT) @@ -196,11 +198,22 @@ class ConfigManager: config["secret_mode_enabled"] = bool(enabled) self.save_config(config) + def set_offline_mode(self, enabled: bool) -> None: + """Persist the offline mode toggle.""" + config = self.load_config(require_pin=False) + config["offline_mode"] = bool(enabled) + self.save_config(config) + def get_secret_mode_enabled(self) -> bool: """Retrieve whether secret mode is enabled.""" config = self.load_config(require_pin=False) return bool(config.get("secret_mode_enabled", False)) + def get_offline_mode(self) -> bool: + """Retrieve the offline mode setting.""" + config = self.load_config(require_pin=False) + return bool(config.get("offline_mode", False)) + def set_clipboard_clear_delay(self, delay: int) -> None: """Persist clipboard clear timeout in seconds.""" if delay <= 0: diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index ceabd04..451d71d 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -135,6 +135,7 @@ class PasswordManager: self.inactivity_timeout: float = INACTIVITY_TIMEOUT self.secret_mode_enabled: bool = False self.clipboard_clear_delay: int = 45 + self.offline_mode: bool = False self.profile_stack: list[tuple[str, Path, str]] = [] self.last_unlock_duration: float | None = None @@ -982,17 +983,19 @@ class PasswordManager: # Load relay configuration and initialize NostrClient config = self.config_manager.load_config() relay_list = config.get("relays", list(DEFAULT_RELAYS)) + self.offline_mode = bool(config.get("offline_mode", False)) self.inactivity_timeout = config.get( "inactivity_timeout", INACTIVITY_TIMEOUT ) self.secret_mode_enabled = bool(config.get("secret_mode_enabled", False)) self.clipboard_clear_delay = int(config.get("clipboard_clear_delay", 45)) - - print("Connecting to relays...") + if not self.offline_mode: + print("Connecting to relays...") self.nostr_client = NostrClient( encryption_manager=self.encryption_manager, fingerprint=self.current_fingerprint, relays=relay_list, + offline_mode=self.offline_mode, parent_seed=getattr(self, "parent_seed", None), ) @@ -1028,6 +1031,8 @@ class PasswordManager: 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 @@ -3312,6 +3317,8 @@ class PasswordManager: def sync_vault(self, alt_summary: str | None = None) -> str | None: """Publish the current vault contents to Nostr.""" try: + if getattr(self, "offline_mode", False): + return None encrypted = self.get_encrypted_data() if not encrypted: return None diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 6c93192..7de30b5 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -535,6 +535,41 @@ def config_toggle_secret_mode(ctx: typer.Context) -> None: typer.echo(f"Secret mode {status}.") +@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 + try: + enabled = cfg.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) + + typer.echo(f"Offline mode is currently {'ON' if enabled else 'OFF'}") + choice = ( + typer.prompt( + "Enable offline mode? (y/n, blank to keep)", default="", show_default=False + ) + .strip() + .lower() + ) + if choice in ("y", "yes"): + enabled = True + elif choice in ("n", "no"): + enabled = False + + try: + cfg.set_offline_mode(enabled) + pm.offline_mode = enabled + except Exception as exc: # pragma: no cover - pass through errors + typer.echo(f"Error: {exc}") + raise typer.Exit(code=1) + + status = "enabled" if enabled else "disabled" + typer.echo(f"Offline mode {status}.") + + @fingerprint_app.command("list") def fingerprint_list(ctx: typer.Context) -> None: """List available seed profiles.""" diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py index 48c8692..e9012d4 100644 --- a/src/tests/test_cli_doc_examples.py +++ b/src/tests/test_cli_doc_examples.py @@ -63,8 +63,10 @@ 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_offline_mode=lambda v: None, get_secret_mode_enabled=lambda: True, get_clipboard_clear_delay=lambda: 30, + get_offline_mode=lambda: False, ) self.secret_mode_enabled = True self.clipboard_clear_delay = 30 diff --git a/src/tests/test_cli_toggle_offline_mode.py b/src/tests/test_cli_toggle_offline_mode.py new file mode 100644 index 0000000..0a46477 --- /dev/null +++ b/src/tests/test_cli_toggle_offline_mode.py @@ -0,0 +1,40 @@ +from types import SimpleNamespace +from typer.testing import CliRunner + +from seedpass.cli import app +from seedpass import cli + +runner = CliRunner() + + +def _make_pm(called, enabled=False): + cfg = SimpleNamespace( + get_offline_mode=lambda: enabled, + set_offline_mode=lambda v: called.setdefault("enabled", v), + ) + pm = SimpleNamespace( + config_manager=cfg, + offline_mode=enabled, + select_fingerprint=lambda fp: None, + ) + return pm + + +def test_toggle_offline_updates(monkeypatch): + called = {} + pm = _make_pm(called) + monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + result = runner.invoke(app, ["config", "toggle-offline"], input="y\n") + assert result.exit_code == 0 + assert called == {"enabled": True} + assert "Offline mode enabled." in result.stdout + + +def test_toggle_offline_keep(monkeypatch): + called = {} + pm = _make_pm(called, enabled=True) + monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + result = runner.invoke(app, ["config", "toggle-offline"], input="\n") + assert result.exit_code == 0 + assert called == {"enabled": True} + assert "Offline mode enabled." in result.stdout diff --git a/src/tests/test_offline_mode_behavior.py b/src/tests/test_offline_mode_behavior.py new file mode 100644 index 0000000..0480207 --- /dev/null +++ b/src/tests/test_offline_mode_behavior.py @@ -0,0 +1,27 @@ +import time +from types import SimpleNamespace + +from password_manager.manager import PasswordManager + + +def test_sync_vault_skips_network(monkeypatch): + pm = PasswordManager.__new__(PasswordManager) + pm.offline_mode = True + pm.get_encrypted_data = lambda: b"data" + called = {"nostr": False} + pm.nostr_client = SimpleNamespace( + publish_snapshot=lambda *a, **kw: called.__setitem__("nostr", True) + ) + result = PasswordManager.sync_vault(pm) + assert result is None + assert called["nostr"] is False + + +def test_start_background_sync_offline(monkeypatch): + pm = PasswordManager.__new__(PasswordManager) + pm.offline_mode = True + called = {"sync": False} + pm.sync_index_from_nostr = lambda: called.__setitem__("sync", True) + PasswordManager.start_background_sync(pm) + time.sleep(0.05) + assert called["sync"] is False