Update settings menu test for new option

This commit is contained in:
thePR0M3TH3AN
2025-07-03 23:55:27 -04:00
parent 838ef90ddf
commit 9b9fc038d3
13 changed files with 326 additions and 25 deletions

View File

@@ -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:**

View File

@@ -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

View File

@@ -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"))

View File

@@ -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))

View File

@@ -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]:

View File

@@ -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"

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)]

View File

@@ -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)

View File

@@ -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",
]

16
src/utils/clipboard.py Normal file
View File

@@ -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()