From 4801e2c33c1c55cdbb20a863aa683bb5e9b4d8a0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 30 Jun 2025 10:06:51 -0400 Subject: [PATCH] Add inactivity lock feature --- src/constants.py | 3 +++ src/main.py | 27 ++++++++++++++++++++++-- src/password_manager/manager.py | 30 ++++++++++++++++++++++++++ src/tests/test_auto_sync.py | 4 ++++ src/tests/test_inactivity_lock.py | 35 +++++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 src/tests/test_inactivity_lock.py diff --git a/src/constants.py b/src/constants.py index 577236d..397ef3c 100644 --- a/src/constants.py +++ b/src/constants.py @@ -52,6 +52,9 @@ DEFAULT_PASSWORD_LENGTH = 16 # Default length for generated passwords MIN_PASSWORD_LENGTH = 8 # Minimum allowed password length MAX_PASSWORD_LENGTH = 128 # Maximum allowed password length +# Timeout in seconds before the vault locks due to inactivity +INACTIVITY_TIMEOUT = 15 * 60 # 15 minutes + # ----------------------------------- # Additional Constants (if any) # ----------------------------------- diff --git a/src/main.py b/src/main.py index 31fd3a7..9d105e7 100644 --- a/src/main.py +++ b/src/main.py @@ -12,6 +12,7 @@ import traceback from password_manager.manager import PasswordManager from nostr.client import NostrClient +from constants import INACTIVITY_TIMEOUT colorama_init() @@ -369,6 +370,7 @@ def handle_profiles_menu(password_manager: PasswordManager) -> None: print("4. List All Seed Profiles") print("5. Back") choice = input("Select an option: ").strip() + password_manager.update_activity() if choice == "1": if not password_manager.handle_switch_fingerprint(): print(colored("Failed to switch seed profile.", "red")) @@ -407,6 +409,7 @@ def handle_nostr_menu(password_manager: PasswordManager) -> None: print("7. Display Nostr Public Key") print("8. Back") choice = input("Select an option: ").strip() + password_manager.update_activity() if choice == "1": handle_post_to_nostr(password_manager) elif choice == "2": @@ -436,7 +439,8 @@ def handle_settings(password_manager: PasswordManager) -> None: print("3. Change password") print("4. Verify Script Checksum") print("5. Backup Parent Seed") - print("6. Back") + print("6. Lock Vault") + print("7. Back") choice = input("Select an option: ").strip() if choice == "1": handle_profiles_menu(password_manager) @@ -449,12 +453,20 @@ def handle_settings(password_manager: PasswordManager) -> None: elif choice == "5": password_manager.handle_backup_reveal_parent_seed() elif choice == "6": + password_manager.lock_vault() + print(colored("Vault locked. Please re-enter your password.", "yellow")) + password_manager.unlock_vault() + elif choice == "7": break else: print(colored("Invalid choice.", "red")) -def display_menu(password_manager: PasswordManager, sync_interval: float = 60.0): +def display_menu( + password_manager: PasswordManager, + sync_interval: float = 60.0, + inactivity_timeout: float = INACTIVITY_TIMEOUT, +): """ Displays the interactive menu and handles user input to perform various actions. """ @@ -466,7 +478,13 @@ def display_menu(password_manager: PasswordManager, sync_interval: float = 60.0) 4. Settings 5. Exit """ + password_manager.update_activity() while True: + if time.time() - password_manager.last_activity > inactivity_timeout: + print(colored("Session timed out. Vault locked.", "yellow")) + password_manager.lock_vault() + password_manager.unlock_vault() + continue # Periodically push updates to Nostr if ( password_manager.is_dirty @@ -480,6 +498,7 @@ def display_menu(password_manager: PasswordManager, sync_interval: float = 60.0) handler.flush() print(colored(menu, "cyan")) choice = input("Enter your choice (1-5): ").strip() + password_manager.update_activity() if not choice: print( colored( @@ -494,6 +513,7 @@ def display_menu(password_manager: PasswordManager, sync_interval: float = 60.0) print("1. Password") print("2. Back") sub_choice = input("Select entry type: ").strip() + password_manager.update_activity() if sub_choice == "1": password_manager.handle_add_password() break @@ -502,10 +522,13 @@ def display_menu(password_manager: PasswordManager, sync_interval: float = 60.0) else: print(colored("Invalid choice.", "red")) elif choice == "2": + password_manager.update_activity() password_manager.handle_retrieve_entry() elif choice == "3": + password_manager.update_activity() password_manager.handle_modify_entry() elif choice == "4": + password_manager.update_activity() handle_settings(password_manager) elif choice == "5": logging.info("Exiting the program.") diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index c4a0494..1338f2d 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -88,6 +88,8 @@ class PasswordManager: # Track changes to trigger periodic Nostr sync self.is_dirty: bool = False self.last_update: float = time.time() + self.last_activity: float = time.time() + self.locked: bool = False # Initialize the fingerprint manager first self.initialize_fingerprint_manager() @@ -98,6 +100,34 @@ class PasswordManager: # Set the current fingerprint directory self.fingerprint_dir = self.fingerprint_manager.get_current_fingerprint_dir() + def update_activity(self) -> None: + """Record the current time as the last user activity.""" + self.last_activity = time.time() + + def lock_vault(self) -> None: + """Clear sensitive information from memory.""" + self.parent_seed = None + 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 + + def unlock_vault(self) -> None: + """Prompt for password and reinitialize managers.""" + if not self.fingerprint_dir: + raise ValueError("Fingerprint directory not set") + self.setup_encryption_manager(self.fingerprint_dir) + self.load_parent_seed(self.fingerprint_dir) + self.initialize_bip85() + self.initialize_managers() + self.locked = False + self.update_activity() + def initialize_fingerprint_manager(self): """ Initializes the FingerprintManager. diff --git a/src/tests/test_auto_sync.py b/src/tests/test_auto_sync.py index af0fef0..322e07c 100644 --- a/src/tests/test_auto_sync.py +++ b/src/tests/test_auto_sync.py @@ -14,10 +14,14 @@ def test_auto_sync_triggers_post(monkeypatch): pm = SimpleNamespace( is_dirty=True, last_update=time.time() - 0.2, + last_activity=time.time(), nostr_client=SimpleNamespace(close_client_pool=lambda: None), handle_add_password=lambda: None, handle_retrieve_entry=lambda: None, handle_modify_entry=lambda: None, + update_activity=lambda: None, + lock_vault=lambda: None, + unlock_vault=lambda: None, ) called = False diff --git a/src/tests/test_inactivity_lock.py b/src/tests/test_inactivity_lock.py new file mode 100644 index 0000000..2958c06 --- /dev/null +++ b/src/tests/test_inactivity_lock.py @@ -0,0 +1,35 @@ +import time +from types import SimpleNamespace +from pathlib import Path +import pytest + +import sys + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +import main + + +def test_inactivity_triggers_lock(monkeypatch): + locked = {"locked": False, "unlocked": False} + + pm = SimpleNamespace( + is_dirty=False, + last_update=time.time(), + last_activity=time.time() - 1.0, + nostr_client=SimpleNamespace(close_client_pool=lambda: None), + handle_add_password=lambda: None, + handle_retrieve_entry=lambda: None, + handle_modify_entry=lambda: None, + update_activity=lambda: None, + lock_vault=lambda: locked.update(locked=True) or None, + unlock_vault=lambda: locked.update(unlocked=True) or None, + ) + + monkeypatch.setattr("builtins.input", lambda _: "5") + + with pytest.raises(SystemExit): + main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1) + + assert locked["locked"] + assert locked["unlocked"]