Add vault locked flag and enforce access checks

This commit is contained in:
thePR0M3TH3AN
2025-08-12 08:48:19 -04:00
parent b0e4ab9bc6
commit 19577163cf
3 changed files with 82 additions and 9 deletions

View File

@@ -24,6 +24,7 @@ import threading
import queue import queue
from dataclasses import dataclass from dataclasses import dataclass
import dataclasses import dataclasses
from functools import wraps
from termcolor import colored from termcolor import colored
from utils.color_scheme import color_text from utils.color_scheme import color_text
from utils.input_utils import timed_input from utils.input_utils import timed_input
@@ -72,6 +73,7 @@ from utils.fingerprint import generate_fingerprint
from utils.atomic_write import atomic_write from utils.atomic_write import atomic_write
from constants import MIN_HEALTHY_RELAYS from constants import MIN_HEALTHY_RELAYS
from .migrations import LATEST_VERSION from .migrations import LATEST_VERSION
from ..errors import VaultLockedError
from constants import ( from constants import (
APP_DIR, APP_DIR,
@@ -164,6 +166,18 @@ class Notification:
level: str = "INFO" level: str = "INFO"
def requires_unlocked(func):
"""Decorator to ensure the vault is unlocked before proceeding."""
@wraps(func)
def wrapper(self, *args, **kwargs):
if getattr(self, "is_locked", False):
raise VaultLockedError("Vault is locked")
return func(self, *args, **kwargs)
return wrapper
class AuthGuard: class AuthGuard:
"""Helper to enforce inactivity timeouts.""" """Helper to enforce inactivity timeouts."""
@@ -228,6 +242,7 @@ class PasswordManager:
self.last_update: float = time.time() self.last_update: float = time.time()
self.last_activity: float = time.time() self.last_activity: float = time.time()
self.locked: bool = False self.locked: bool = False
self.is_locked: bool = False
self.inactivity_timeout: float = INACTIVITY_TIMEOUT self.inactivity_timeout: float = INACTIVITY_TIMEOUT
self.secret_mode_enabled: bool = False self.secret_mode_enabled: bool = False
self.clipboard_clear_delay: int = 45 self.clipboard_clear_delay: int = 45
@@ -264,6 +279,7 @@ class PasswordManager:
self.fingerprint_manager.get_current_fingerprint_dir() self.fingerprint_manager.get_current_fingerprint_dir()
) )
@requires_unlocked
def get_bip85_entropy(self, purpose: int, index: int, bytes_len: int = 32) -> bytes: def get_bip85_entropy(self, purpose: int, index: int, bytes_len: int = 32) -> bytes:
"""Return deterministic entropy via the cached BIP-85 function.""" """Return deterministic entropy via the cached BIP-85 function."""
@@ -273,6 +289,7 @@ class PasswordManager:
index=index, bytes_len=bytes_len, app_no=purpose index=index, bytes_len=bytes_len, app_no=purpose
) )
@requires_unlocked
def clear_bip85_cache(self) -> None: def clear_bip85_cache(self) -> None:
"""Clear the internal BIP-85 cache.""" """Clear the internal BIP-85 cache."""
@@ -400,6 +417,8 @@ class PasswordManager:
@property @property
def entry_service(self) -> EntryService: def entry_service(self) -> EntryService:
if getattr(self, "is_locked", False):
raise VaultLockedError("Vault is locked")
if getattr(self, "_entry_service", None) is None: if getattr(self, "_entry_service", None) is None:
self._entry_service = EntryService(self) self._entry_service = EntryService(self)
return self._entry_service return self._entry_service
@@ -408,15 +427,7 @@ class PasswordManager:
"""Clear sensitive information from memory.""" """Clear sensitive information from memory."""
if self.entry_manager is not None: if self.entry_manager is not None:
self.entry_manager.clear_cache() self.entry_manager.clear_cache()
self.parent_seed = None self.is_locked = True
self.encryption_manager = None
self.entry_manager = None
self.password_generator = None
self.backup_manager = None
self.vault = None
self.bip85 = None
self.nostr_client = None
self.config_manager = None
self.locked = True self.locked = True
bus.publish("vault_locked") bus.publish("vault_locked")
@@ -441,6 +452,7 @@ class PasswordManager:
self.setup_encryption_manager(self.fingerprint_dir, password) self.setup_encryption_manager(self.fingerprint_dir, password)
self.initialize_bip85() self.initialize_bip85()
self.initialize_managers() self.initialize_managers()
self.is_locked = False
self.locked = False self.locked = False
self.update_activity() self.update_activity()
if ( if (
@@ -746,9 +758,11 @@ class PasswordManager:
print(colored(f"Error: Failed to load parent seed: {e}", "red")) print(colored(f"Error: Failed to load parent seed: {e}", "red"))
sys.exit(1) sys.exit(1)
@requires_unlocked
def handle_switch_fingerprint(self, *, password: Optional[str] = None) -> bool: def handle_switch_fingerprint(self, *, password: Optional[str] = None) -> bool:
return self.profile_service.handle_switch_fingerprint(password=password) return self.profile_service.handle_switch_fingerprint(password=password)
@requires_unlocked
def load_managed_account(self, index: int) -> None: def load_managed_account(self, index: int) -> None:
"""Load a managed account derived from the current seed profile.""" """Load a managed account derived from the current seed profile."""
if not self.entry_manager or not self.parent_seed: if not self.entry_manager or not self.parent_seed:
@@ -777,6 +791,7 @@ class PasswordManager:
self.update_activity() self.update_activity()
self.start_background_sync() self.start_background_sync()
@requires_unlocked
def exit_managed_account(self) -> None: def exit_managed_account(self) -> None:
"""Return to the parent seed profile if one is on the stack.""" """Return to the parent seed profile if one is on the stack."""
if not self.profile_stack: if not self.profile_stack:
@@ -1041,6 +1056,7 @@ class PasswordManager:
print(colored("Error: Invalid BIP-85 seed phrase.", "red")) print(colored("Error: Invalid BIP-85 seed phrase.", "red"))
sys.exit(1) sys.exit(1)
@requires_unlocked
def generate_new_seed(self) -> Optional[str]: def generate_new_seed(self) -> Optional[str]:
""" """
Generates a new BIP-85 seed, displays it to the user, and prompts for confirmation before saving. Generates a new BIP-85 seed, displays it to the user, and prompts for confirmation before saving.
@@ -1146,6 +1162,7 @@ class PasswordManager:
print(colored(f"Error: Failed to generate BIP-85 seed: {e}", "red")) print(colored(f"Error: Failed to generate BIP-85 seed: {e}", "red"))
sys.exit(1) sys.exit(1)
@requires_unlocked
def save_and_encrypt_seed( def save_and_encrypt_seed(
self, seed: str, fingerprint_dir: Path, *, password: Optional[str] = None self, seed: str, fingerprint_dir: Path, *, password: Optional[str] = None
) -> None: ) -> None:

4
src/seedpass/errors.py Normal file
View File

@@ -0,0 +1,4 @@
class VaultLockedError(Exception):
"""Raised when an operation requires an unlocked vault."""
pass

View File

@@ -0,0 +1,52 @@
import pytest
from types import SimpleNamespace
from seedpass.core.manager import PasswordManager
from seedpass.errors import VaultLockedError
class DummyEntryManager:
def __init__(self):
self.cleared = False
def clear_cache(self):
self.cleared = True
def test_lock_vault_sets_flag_and_keeps_objects():
pm = PasswordManager.__new__(PasswordManager)
em = DummyEntryManager()
pm.entry_manager = em
pm.is_locked = False
pm.locked = False
pm.lock_vault()
assert pm.is_locked
assert pm.locked
assert pm.entry_manager is em
assert em.cleared
def test_entry_service_requires_unlocked():
pm = PasswordManager.__new__(PasswordManager)
service = SimpleNamespace()
pm._entry_service = service
pm.is_locked = True
with pytest.raises(VaultLockedError):
_ = pm.entry_service
pm.is_locked = False
assert pm.entry_service is service
def test_unlock_vault_clears_locked_flag(tmp_path):
pm = PasswordManager.__new__(PasswordManager)
pm.fingerprint_dir = tmp_path
pm.parent_seed = "seed"
pm.setup_encryption_manager = lambda *a, **k: None
pm.initialize_bip85 = lambda: None
pm.initialize_managers = lambda: None
pm.update_activity = lambda: None
pm.is_locked = True
pm.locked = True
pm.unlock_vault("pw")
assert not pm.is_locked
assert not pm.locked