diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 620678c..4b9a917 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 74a0da1..a707391 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 32d81da..2befaed 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 eb71f62..3d51993 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 = {}