mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-07 14:58:56 +00:00
Add offline mode feature
This commit is contained in:
@@ -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
|
||||
|
||||
|
@@ -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 <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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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()
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
||||
|
@@ -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."""
|
||||
|
@@ -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
|
||||
|
40
src/tests/test_cli_toggle_offline_mode.py
Normal file
40
src/tests/test_cli_toggle_offline_mode.py
Normal 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
|
27
src/tests/test_offline_mode_behavior.py
Normal file
27
src/tests/test_offline_mode_behavior.py
Normal 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
|
Reference in New Issue
Block a user