Add offline mode feature

This commit is contained in:
thePR0M3TH3AN
2025-07-13 13:15:05 -04:00
parent 3d71fc5298
commit cca860adf5
10 changed files with 164 additions and 6 deletions

View File

@@ -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

View File

@@ -93,6 +93,7 @@ Manage profilespecific 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 <key>`** 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 <key> <value>`** 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

View File

@@ -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

View File

@@ -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()

View File

@@ -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:

View File

@@ -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

View File

@@ -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."""

View File

@@ -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

View File

@@ -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

View File

@@ -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