diff --git a/README.md b/README.md index 1b94308..2355ea0 100644 --- a/README.md +++ b/README.md @@ -266,9 +266,10 @@ Back in the Settings menu you can: * Select `8` to export all 2FA codes. * Choose `9` to set an additional backup location. * Select `10` to change the inactivity timeout. -* Choose `11` to lock the vault and require re-entry of your password. -* Select `12` to return to the main menu. -* Choose `13` to view seed profile stats. +* Choose `11` to toggle Secret Mode and set the clipboard clear delay. +* Select `12` to lock the vault and require re-entry of your password. +* Choose `13` to return to the main menu. +* Select `14` to view seed profile stats. ## Running Tests @@ -440,6 +441,7 @@ The SeedPass roadmap outlines a structured development plan divided into distinc - **Toggle Setting:** Allow users to enable or disable "secret" mode. - **Clipboard Integration:** Ensure passwords are copied securely to the clipboard when "secret" mode is active. - **User Feedback:** Notify users that the password has been copied to the clipboard. + - **Settings Menu:** Toggle this mode under `Settings -> Toggle Secret Mode` and set how long the clipboard is retained. - **Two-Factor Security Model with Random Index Generation** - **Description:** Create a robust two-factor security system using a master seed and master password combination, enhanced with random index generation for additional security. - **Key Features:** diff --git a/docs/advanced_cli.md b/docs/advanced_cli.md index 7e355d5..4288b9b 100644 --- a/docs/advanced_cli.md +++ b/docs/advanced_cli.md @@ -494,6 +494,8 @@ seedpass set-secret --disable - `--enable`: Activates "secret" mode. - `--disable`: Deactivates "secret" mode. +You can also enable or disable secret mode from the interactive Settings menu by selecting **Toggle Secret Mode**. + --- ### 21. Batch Post Snapshot Deltas to Nostr diff --git a/src/main.py b/src/main.py index b6407b2..9ddbf92 100644 --- a/src/main.py +++ b/src/main.py @@ -499,6 +499,47 @@ def handle_set_additional_backup_location(pm: PasswordManager) -> None: print(colored(f"Error: {e}", "red")) +def handle_toggle_secret_mode(pm: PasswordManager) -> None: + """Toggle secret mode and adjust clipboard delay.""" + cfg = pm.config_manager + if cfg is None: + print(colored("Configuration manager unavailable.", "red")) + return + try: + enabled = cfg.get_secret_mode_enabled() + delay = cfg.get_clipboard_clear_delay() + except Exception as exc: + logging.error(f"Error loading secret mode settings: {exc}") + print(colored(f"Error loading settings: {exc}", "red")) + return + print(colored(f"Secret mode is currently {'ON' if enabled else 'OFF'}", "cyan")) + value = input("Enable secret mode? (y/n, blank to keep): ").strip().lower() + if value in ("y", "yes"): + enabled = True + elif value in ("n", "no"): + enabled = False + dur = input(f"Clipboard clear delay in seconds [{delay}]: ").strip() + if dur: + try: + delay = int(dur) + if delay <= 0: + print(colored("Delay must be positive.", "red")) + return + except ValueError: + print(colored("Invalid number.", "red")) + return + try: + cfg.set_secret_mode_enabled(enabled) + cfg.set_clipboard_clear_delay(delay) + pm.secret_mode_enabled = enabled + pm.clipboard_clear_delay = delay + status = "enabled" if enabled else "disabled" + print(colored(f"Secret mode {status}.", "green")) + except Exception as exc: + logging.error(f"Error saving secret mode: {exc}") + print(colored(f"Error: {exc}", "red")) + + def handle_profiles_menu(password_manager: PasswordManager) -> None: """Submenu for managing seed profiles.""" while True: @@ -583,9 +624,10 @@ def handle_settings(password_manager: PasswordManager) -> None: print("8. Export 2FA codes") print("9. Set additional backup location") print("10. Set inactivity timeout") - print("11. Lock Vault") - print("12. Stats") - print("13. Back") + print("11. Toggle Secret Mode") + print("12. Lock Vault") + print("13. Stats") + print("14. Back") choice = input("Select an option: ").strip() if choice == "1": handle_profiles_menu(password_manager) @@ -610,12 +652,14 @@ def handle_settings(password_manager: PasswordManager) -> None: elif choice == "10": handle_set_inactivity_timeout(password_manager) elif choice == "11": + handle_toggle_secret_mode(password_manager) + elif choice == "12": password_manager.lock_vault() print(colored("Vault locked. Please re-enter your password.", "yellow")) password_manager.unlock_vault() - elif choice == "12": - handle_display_stats(password_manager) elif choice == "13": + handle_display_stats(password_manager) + elif choice == "14": break else: print(colored("Invalid choice.", "red")) diff --git a/src/password_manager/config_manager.py b/src/password_manager/config_manager.py index 7c02aae..b1c8b8e 100644 --- a/src/password_manager/config_manager.py +++ b/src/password_manager/config_manager.py @@ -45,6 +45,8 @@ class ConfigManager: "password_hash": "", "inactivity_timeout": INACTIVITY_TIMEOUT, "additional_backup_path": "", + "secret_mode_enabled": False, + "clipboard_clear_delay": 45, } try: data = self.vault.load_config() @@ -56,6 +58,8 @@ class ConfigManager: data.setdefault("password_hash", "") data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT) data.setdefault("additional_backup_path", "") + data.setdefault("secret_mode_enabled", False) + data.setdefault("clipboard_clear_delay", 45) # Migrate legacy hashed_password.enc if present and password_hash is missing legacy_file = self.fingerprint_dir / "hashed_password.enc" @@ -144,3 +148,27 @@ class ConfigManager: config = self.load_config(require_pin=False) value = config.get("additional_backup_path", "") return value or None + + def set_secret_mode_enabled(self, enabled: bool) -> None: + """Persist the secret mode toggle.""" + config = self.load_config(require_pin=False) + config["secret_mode_enabled"] = 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 set_clipboard_clear_delay(self, delay: int) -> None: + """Persist clipboard clear timeout in seconds.""" + if delay <= 0: + raise ValueError("Delay must be positive") + config = self.load_config(require_pin=False) + config["clipboard_clear_delay"] = int(delay) + self.save_config(config) + + def get_clipboard_clear_delay(self) -> int: + """Retrieve clipboard clear delay in seconds.""" + config = self.load_config(require_pin=False) + return int(config.get("clipboard_clear_delay", 45)) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 0f0274c..e6327d5 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -42,6 +42,7 @@ from utils.password_prompt import ( confirm_action, ) from utils.memory_protection import InMemorySecret +from utils.clipboard import copy_to_clipboard from constants import MIN_HEALTHY_RELAYS from constants import ( @@ -106,6 +107,8 @@ class PasswordManager: self.last_activity: float = time.time() self.locked: bool = False self.inactivity_timeout: float = INACTIVITY_TIMEOUT + self.secret_mode_enabled: bool = False + self.clipboard_clear_delay: int = 45 # Initialize the fingerprint manager first self.initialize_fingerprint_manager() @@ -776,6 +779,8 @@ class PasswordManager: 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)) self.nostr_client = NostrClient( encryption_manager=self.encryption_manager, @@ -1021,9 +1026,18 @@ class PasswordManager: try: while True: code = self.entry_manager.get_totp_code(index, self.parent_seed) - print(colored("\n[+] Retrieved 2FA Code:\n", "green")) - print(colored(f"Label: {label}", "cyan")) - print(colored(f"Code: {code}", "yellow")) + if self.secret_mode_enabled: + copy_to_clipboard(code, self.clipboard_clear_delay) + print( + colored( + f"[+] 2FA code for '{label}' copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored("\n[+] Retrieved 2FA Code:\n", "green")) + print(colored(f"Label: {label}", "cyan")) + print(colored(f"Code: {code}", "yellow")) if notes: print(colored(f"Notes: {notes}", "cyan")) remaining = self.entry_manager.get_totp_time_remaining(index) @@ -1084,18 +1098,30 @@ class PasswordManager: password = self.password_generator.generate_password(length, index) if password: - print( - colored(f"\n[+] Retrieved Password for {website_name}:\n", "green") - ) - print(colored(f"Password: {password}", "yellow")) - print(colored(f"Associated Username: {username or 'N/A'}", "cyan")) - print(colored(f"Associated URL: {url or 'N/A'}", "cyan")) - print( - colored( - f"Blacklist Status: {'Blacklisted' if blacklisted else 'Not Blacklisted'}", - "cyan", + if self.secret_mode_enabled: + copy_to_clipboard(password, self.clipboard_clear_delay) + print( + colored( + f"[+] Password for '{website_name}' copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print( + colored( + f"\n[+] Retrieved Password for {website_name}:\n", + "green", + ) + ) + print(colored(f"Password: {password}", "yellow")) + print(colored(f"Associated Username: {username or 'N/A'}", "cyan")) + print(colored(f"Associated URL: {url or 'N/A'}", "cyan")) + print( + colored( + f"Blacklist Status: {'Blacklisted' if blacklisted else 'Not Blacklisted'}", + "cyan", + ) ) - ) else: print(colored("Error: Failed to retrieve the password.", "red")) except Exception as e: @@ -1402,7 +1428,13 @@ class PasswordManager: remaining = self.entry_manager.get_totp_time_remaining(idx) filled = int(20 * (period - remaining) / period) bar = "[" + "#" * filled + "-" * (20 - filled) + "]" - print(f"[{idx}] {label}: {code} {bar} {remaining:2d}s") + if self.secret_mode_enabled: + copy_to_clipboard(code, self.clipboard_clear_delay) + print( + f"[{idx}] {label}: [HIDDEN] {bar} {remaining:2d}s - copied to clipboard" + ) + else: + print(f"[{idx}] {label}: {code} {bar} {remaining:2d}s") if imported_list: print(colored("\nImported 2FA Codes:", "green")) for label, idx, period, _ in imported_list: @@ -1410,7 +1442,13 @@ class PasswordManager: remaining = self.entry_manager.get_totp_time_remaining(idx) filled = int(20 * (period - remaining) / period) bar = "[" + "#" * filled + "-" * (20 - filled) + "]" - print(f"[{idx}] {label}: {code} {bar} {remaining:2d}s") + if self.secret_mode_enabled: + copy_to_clipboard(code, self.clipboard_clear_delay) + print( + f"[{idx}] {label}: [HIDDEN] {bar} {remaining:2d}s - copied to clipboard" + ) + else: + print(f"[{idx}] {label}: {code} {bar} {remaining:2d}s") sys.stdout.flush() try: if sys.stdin in select.select([sys.stdin], [], [], 1)[0]: diff --git a/src/tests/test_clipboard_utils.py b/src/tests/test_clipboard_utils.py new file mode 100644 index 0000000..5aa5068 --- /dev/null +++ b/src/tests/test_clipboard_utils.py @@ -0,0 +1,68 @@ +from pathlib import Path +import pyperclip +import threading + +import sys + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from utils.clipboard import copy_to_clipboard + + +def test_copy_to_clipboard_clears(monkeypatch): + clipboard = {"text": ""} + + def fake_copy(val): + clipboard["text"] = val + + def fake_paste(): + return clipboard["text"] + + callbacks = {} + + class DummyTimer: + def __init__(self, delay, func): + callbacks["delay"] = delay + callbacks["func"] = func + + def start(self): + callbacks["started"] = True + + monkeypatch.setattr(pyperclip, "copy", fake_copy) + monkeypatch.setattr(pyperclip, "paste", fake_paste) + monkeypatch.setattr(threading, "Timer", DummyTimer) + + copy_to_clipboard("secret", 2) + assert clipboard["text"] == "secret" + assert callbacks["delay"] == 2 + assert callbacks["started"] + callbacks["func"]() + assert clipboard["text"] == "" + + +def test_copy_to_clipboard_does_not_clear_if_changed(monkeypatch): + clipboard = {"text": ""} + + def fake_copy(val): + clipboard["text"] = val + + def fake_paste(): + return clipboard["text"] + + callbacks = {} + + class DummyTimer: + def __init__(self, delay, func): + callbacks["func"] = func + + def start(self): + pass + + monkeypatch.setattr(pyperclip, "copy", fake_copy) + monkeypatch.setattr(pyperclip, "paste", fake_paste) + monkeypatch.setattr(threading, "Timer", DummyTimer) + + copy_to_clipboard("secret", 1) + fake_copy("other") + callbacks["func"]() + assert clipboard["text"] == "other" diff --git a/src/tests/test_config_manager.py b/src/tests/test_config_manager.py index 5be4603..b035a6b 100644 --- a/src/tests/test_config_manager.py +++ b/src/tests/test_config_manager.py @@ -130,3 +130,19 @@ def test_additional_backup_path_round_trip(): cfg_mgr.set_additional_backup_path(None) cfg2 = cfg_mgr.load_config(require_pin=False) assert cfg2["additional_backup_path"] == "" + + +def test_secret_mode_round_trip(): + with TemporaryDirectory() as tmpdir: + vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, Path(tmpdir)) + + cfg = cfg_mgr.load_config(require_pin=False) + assert cfg["secret_mode_enabled"] is False + assert cfg["clipboard_clear_delay"] == 45 + + cfg_mgr.set_secret_mode_enabled(True) + cfg_mgr.set_clipboard_clear_delay(99) + cfg2 = cfg_mgr.load_config(require_pin=False) + assert cfg2["secret_mode_enabled"] is True + assert cfg2["clipboard_clear_delay"] == 99 diff --git a/src/tests/test_manager_display_totp_codes.py b/src/tests/test_manager_display_totp_codes.py index bff96e8..c9f12a7 100644 --- a/src/tests/test_manager_display_totp_codes.py +++ b/src/tests/test_manager_display_totp_codes.py @@ -39,6 +39,7 @@ def test_handle_display_totp_codes(monkeypatch, capsys): pm.nostr_client = FakeNostrClient() pm.fingerprint_dir = tmp_path pm.is_dirty = False + pm.secret_mode_enabled = False entry_mgr.add_totp("Example", TEST_SEED) @@ -78,6 +79,7 @@ def test_display_totp_codes_excludes_blacklisted(monkeypatch, capsys): pm.nostr_client = FakeNostrClient() pm.fingerprint_dir = tmp_path pm.is_dirty = False + pm.secret_mode_enabled = False entry_mgr.add_totp("Visible", TEST_SEED) entry_mgr.add_totp("Hidden", TEST_SEED) diff --git a/src/tests/test_manager_retrieve_totp.py b/src/tests/test_manager_retrieve_totp.py index 0ee97e8..e127773 100644 --- a/src/tests/test_manager_retrieve_totp.py +++ b/src/tests/test_manager_retrieve_totp.py @@ -39,6 +39,7 @@ def test_handle_retrieve_totp_entry(monkeypatch, capsys): pm.nostr_client = FakeNostrClient() pm.fingerprint_dir = tmp_path pm.is_dirty = False + pm.secret_mode_enabled = False entry_mgr.add_totp("Example", TEST_SEED) diff --git a/src/tests/test_secret_mode.py b/src/tests/test_secret_mode.py new file mode 100644 index 0000000..f3a8b9a --- /dev/null +++ b/src/tests/test_secret_mode.py @@ -0,0 +1,82 @@ +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace + +from helpers import create_vault, TEST_SEED, TEST_PASSWORD + +import sys + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager +from password_manager.manager import PasswordManager, EncryptionMode +from password_manager.config_manager import ConfigManager + + +def setup_pm(tmp_path): + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw") + pm.parent_seed = TEST_SEED + pm.nostr_client = SimpleNamespace() + pm.fingerprint_dir = tmp_path + pm.config_manager = cfg_mgr + pm.secret_mode_enabled = True + pm.clipboard_clear_delay = 5 + return pm, entry_mgr + + +def test_password_retrieve_secret_mode(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + pm, entry_mgr = setup_pm(tmp) + entry_mgr.add_entry("example", 8) + + monkeypatch.setattr("builtins.input", lambda *a, **k: "0") + called = [] + monkeypatch.setattr( + "password_manager.manager.copy_to_clipboard", + lambda text, t: called.append((text, t)), + ) + + pm.handle_retrieve_entry() + out = capsys.readouterr().out + assert "Password:" not in out + assert "copied to clipboard" in out + assert called == [("pw", 5)] + + +def test_totp_display_secret_mode(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + pm, entry_mgr = setup_pm(tmp) + entry_mgr.add_totp("Example", TEST_SEED) + + monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456") + monkeypatch.setattr( + pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 30 + ) + monkeypatch.setattr( + "password_manager.manager.select.select", + lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + called = [] + monkeypatch.setattr( + "password_manager.manager.copy_to_clipboard", + lambda text, t: called.append((text, t)), + ) + + pm.handle_display_totp_codes() + out = capsys.readouterr().out + assert "123456" not in out + assert "copied to clipboard" in out + assert called == [("123456", 5)] diff --git a/src/tests/test_settings_menu.py b/src/tests/test_settings_menu.py index 61b2a5a..1ef8a25 100644 --- a/src/tests/test_settings_menu.py +++ b/src/tests/test_settings_menu.py @@ -93,7 +93,7 @@ def test_settings_menu_additional_backup(monkeypatch): tmp_path = Path(tmpdir) pm, cfg_mgr, fp_mgr = setup_pm(tmp_path, monkeypatch) - inputs = iter(["9", "13"]) + inputs = iter(["9", "14"]) with patch("main.handle_set_additional_backup_location") as handler: with patch("builtins.input", side_effect=lambda *_: next(inputs)): main.handle_settings(pm) diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 95a731d..2ff4329 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -25,6 +25,7 @@ try: from .password_prompt import prompt_for_password from .input_utils import timed_input from .memory_protection import InMemorySecret + from .clipboard import copy_to_clipboard if logger.isEnabledFor(logging.DEBUG): logger.info("Modules imported successfully.") @@ -49,4 +50,5 @@ __all__ = [ "prompt_for_password", "timed_input", "InMemorySecret", + "copy_to_clipboard", ] diff --git a/src/utils/clipboard.py b/src/utils/clipboard.py new file mode 100644 index 0000000..e576419 --- /dev/null +++ b/src/utils/clipboard.py @@ -0,0 +1,16 @@ +import threading +import pyperclip + + +def copy_to_clipboard(text: str, timeout: int) -> None: + """Copy text to the clipboard and clear after timeout seconds if unchanged.""" + + pyperclip.copy(text) + + def clear_clipboard() -> None: + if pyperclip.paste() == text: + pyperclip.copy("") + + timer = threading.Timer(timeout, clear_clipboard) + timer.daemon = True + timer.start()