Merge pull request #232 from PR0M3TH3AN/beta

Beta
This commit is contained in:
thePR0M3TH3AN
2025-07-04 14:46:42 -04:00
committed by GitHub
11 changed files with 184 additions and 46 deletions

3
.gitignore vendored
View File

@@ -31,4 +31,5 @@ Thumbs.db
coverage.xml coverage.xml
# Other # Other
.hypothesis .hypothesis
totp_export.json.enc

View File

@@ -43,7 +43,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
- **Encrypted Storage:** All seeds, login passwords, and sensitive index data are encrypted locally. - **Encrypted Storage:** All seeds, login passwords, and sensitive index data are encrypted locally.
- **Nostr Integration:** Post and retrieve your encrypted password index to/from the Nostr network. - **Nostr Integration:** Post and retrieve your encrypted password index to/from the Nostr network.
- **Chunked Snapshots:** Encrypted vaults are compressed and split into 50KB chunks published as `kind 30071` events with a `kind 30070` manifest and `kind 30072` deltas. - **Chunked Snapshots:** Encrypted vaults are compressed and split into 50KB chunks published as `kind 30071` events with a `kind 30070` manifest and `kind 30072` deltas.
- **Checksum Verification:** Ensure the integrity of the script with checksum verification. - **Automatic Checksum Generation:** The script generates and verifies a SHA-256 checksum to detect tampering.
- **Multiple Seed Profiles:** Manage separate seed profiles and switch between them seamlessly. - **Multiple Seed Profiles:** Manage separate seed profiles and switch between them seamlessly.
- **Interactive TUI:** Navigate through menus to add, retrieve, and modify entries as well as configure Nostr settings. - **Interactive TUI:** Navigate through menus to add, retrieve, and modify entries as well as configure Nostr settings.
- **SeedPass 2FA:** Generate TOTP codes with a real-time countdown progress bar. - **SeedPass 2FA:** Generate TOTP codes with a real-time countdown progress bar.
@@ -110,6 +110,16 @@ pip install --upgrade pip
pip install -r src/requirements.txt pip install -r src/requirements.txt
``` ```
#### Linux Clipboard Support
On Linux, `pyperclip` relies on external utilities like `xclip` or `xsel`.
SeedPass will attempt to install **xclip** automatically if neither tool is
available. If the automatic installation fails, you can install it manually:
```bash
sudo apt-get install xclip
```
## Quick Start ## Quick Start
After installing dependencies and activating your virtual environment, launch After installing dependencies and activating your virtual environment, launch
@@ -269,16 +279,17 @@ Back in the Settings menu you can:
* Select `3` to change your master password. * Select `3` to change your master password.
* Choose `4` to verify the script checksum. * Choose `4` to verify the script checksum.
* Choose `5` to back up the parent seed. * Select `5` to generate a new script checksum.
* Select `6` to export the database to an encrypted file. * Choose `6` to back up the parent seed.
* Choose `7` to import a database from a backup file. * Select `7` to export the database to an encrypted file.
* Select `8` to export all 2FA codes. * Choose `8` to import a database from a backup file.
* Choose `9` to set an additional backup location. * Select `9` to export all 2FA codes.
* Select `10` to change the inactivity timeout. * Choose `10` to set an additional backup location.
* Choose `11` to toggle Secret Mode and set the clipboard clear delay. * Select `11` to change the inactivity timeout.
* Select `12` to lock the vault and require re-entry of your password. * Choose `12` to lock the vault and require re-entry of your password.
* Choose `13` to return to the main menu. * Select `13` to view seed profile stats.
* Select `14` to view seed profile stats. * Choose `14` to toggle Secret Mode and set the clipboard clear delay.
* Select `15` to return to the main menu.
## Running Tests ## Running Tests

View File

@@ -7,7 +7,7 @@ SRC_DIR = PROJECT_ROOT / "src"
if str(SRC_DIR) not in sys.path: if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR)) sys.path.insert(0, str(SRC_DIR))
from utils.checksum import calculate_checksum from utils.checksum import update_checksum_file
from constants import SCRIPT_CHECKSUM_FILE, initialize_app from constants import SCRIPT_CHECKSUM_FILE, initialize_app
@@ -15,11 +15,8 @@ def main() -> None:
"""Calculate checksum for the main script and write it to SCRIPT_CHECKSUM_FILE.""" """Calculate checksum for the main script and write it to SCRIPT_CHECKSUM_FILE."""
initialize_app() initialize_app()
script_path = SRC_DIR / "password_manager" / "manager.py" script_path = SRC_DIR / "password_manager" / "manager.py"
checksum = calculate_checksum(str(script_path)) if not update_checksum_file(str(script_path), str(SCRIPT_CHECKSUM_FILE)):
if checksum is None: raise SystemExit(f"Failed to update checksum for {script_path}")
raise SystemExit(f"Failed to calculate checksum for {script_path}")
SCRIPT_CHECKSUM_FILE.write_text(checksum)
print(f"Updated checksum written to {SCRIPT_CHECKSUM_FILE}") print(f"Updated checksum written to {SCRIPT_CHECKSUM_FILE}")

View File

@@ -19,9 +19,8 @@ from nostr.client import NostrClient
from password_manager.entry_types import EntryType from password_manager.entry_types import EntryType
from constants import INACTIVITY_TIMEOUT, initialize_app from constants import INACTIVITY_TIMEOUT, initialize_app
from utils.password_prompt import PasswordPromptError from utils.password_prompt import PasswordPromptError
from utils import timed_input from utils import timed_input, copy_to_clipboard
from local_bip85.bip85 import Bip85Error from local_bip85.bip85 import Bip85Error
import pyperclip
colorama_init() colorama_init()
@@ -618,16 +617,17 @@ def handle_settings(password_manager: PasswordManager) -> None:
print("2. Nostr") print("2. Nostr")
print("3. Change password") print("3. Change password")
print("4. Verify Script Checksum") print("4. Verify Script Checksum")
print("5. Backup Parent Seed") print("5. Generate Script Checksum")
print("6. Export database") print("6. Backup Parent Seed")
print("7. Import database") print("7. Export database")
print("8. Export 2FA codes") print("8. Import database")
print("9. Set additional backup location") print("9. Export 2FA codes")
print("10. Set inactivity timeout") print("10. Set additional backup location")
print("11. Lock Vault") print("11. Set inactivity timeout")
print("12. Stats") print("12. Lock Vault")
print("13. Toggle Secret Mode") print("13. Stats")
print("14. Back") print("14. Toggle Secret Mode")
print("15. Back")
choice = input("Select an option: ").strip() choice = input("Select an option: ").strip()
if choice == "1": if choice == "1":
handle_profiles_menu(password_manager) handle_profiles_menu(password_manager)
@@ -638,28 +638,30 @@ def handle_settings(password_manager: PasswordManager) -> None:
elif choice == "4": elif choice == "4":
password_manager.handle_verify_checksum() password_manager.handle_verify_checksum()
elif choice == "5": elif choice == "5":
password_manager.handle_backup_reveal_parent_seed() password_manager.handle_update_script_checksum()
elif choice == "6": elif choice == "6":
password_manager.handle_export_database() password_manager.handle_backup_reveal_parent_seed()
elif choice == "7": elif choice == "7":
password_manager.handle_export_database()
elif choice == "8":
path = input("Enter path to backup file: ").strip() path = input("Enter path to backup file: ").strip()
if path: if path:
password_manager.handle_import_database(Path(path)) password_manager.handle_import_database(Path(path))
elif choice == "8":
password_manager.handle_export_totp_codes()
elif choice == "9": elif choice == "9":
handle_set_additional_backup_location(password_manager) password_manager.handle_export_totp_codes()
elif choice == "10": elif choice == "10":
handle_set_inactivity_timeout(password_manager) handle_set_additional_backup_location(password_manager)
elif choice == "11": elif choice == "11":
handle_set_inactivity_timeout(password_manager)
elif choice == "12":
password_manager.lock_vault() password_manager.lock_vault()
print(colored("Vault locked. Please re-enter your password.", "yellow")) print(colored("Vault locked. Please re-enter your password.", "yellow"))
password_manager.unlock_vault() password_manager.unlock_vault()
elif choice == "12":
handle_display_stats(password_manager)
elif choice == "13": elif choice == "13":
handle_toggle_secret_mode(password_manager) handle_display_stats(password_manager)
elif choice == "14": elif choice == "14":
handle_toggle_secret_mode(password_manager)
elif choice == "15":
break break
else: else:
print(colored("Invalid choice.", "red")) print(colored("Invalid choice.", "red"))
@@ -852,7 +854,7 @@ def main(argv: list[str] | None = None) -> int:
) )
print(code) print(code)
try: try:
pyperclip.copy(code) copy_to_clipboard(code, password_manager.clipboard_clear_delay)
print(colored("Code copied to clipboard", "green")) print(colored("Code copied to clipboard", "green"))
except Exception as exc: except Exception as exc:
logging.warning(f"Clipboard copy failed: {exc}") logging.warning(f"Clipboard copy failed: {exc}")

View File

@@ -34,7 +34,13 @@ from utils.key_derivation import (
derive_index_key, derive_index_key,
EncryptionMode, EncryptionMode,
) )
from utils.checksum import calculate_checksum, verify_checksum, json_checksum from utils.checksum import (
calculate_checksum,
verify_checksum,
json_checksum,
initialize_checksum,
update_checksum_file,
)
from utils.password_prompt import ( from utils.password_prompt import (
prompt_for_password, prompt_for_password,
prompt_existing_password, prompt_existing_password,
@@ -89,6 +95,7 @@ class PasswordManager:
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize the PasswordManager.""" """Initialize the PasswordManager."""
initialize_app() initialize_app()
self.ensure_script_checksum()
self.encryption_mode: EncryptionMode = EncryptionMode.SEED_ONLY self.encryption_mode: EncryptionMode = EncryptionMode.SEED_ONLY
self.encryption_manager: Optional[EncryptionManager] = None self.encryption_manager: Optional[EncryptionManager] = None
self.entry_manager: Optional[EntryManager] = None self.entry_manager: Optional[EntryManager] = None
@@ -119,6 +126,23 @@ class PasswordManager:
# Set the current fingerprint directory # Set the current fingerprint directory
self.fingerprint_dir = self.fingerprint_manager.get_current_fingerprint_dir() self.fingerprint_dir = self.fingerprint_manager.get_current_fingerprint_dir()
def ensure_script_checksum(self) -> None:
"""Initialize or verify the checksum of the manager script."""
script_path = Path(__file__).resolve()
if not SCRIPT_CHECKSUM_FILE.exists():
initialize_checksum(str(script_path), SCRIPT_CHECKSUM_FILE)
return
checksum = calculate_checksum(str(script_path))
if checksum and not verify_checksum(checksum, SCRIPT_CHECKSUM_FILE):
logging.warning("Script checksum mismatch detected on startup")
print(
colored(
"Warning: script checksum mismatch. "
"Run 'Generate Script Checksum' in Settings if you've updated the app.",
"red",
)
)
@property @property
def parent_seed(self) -> Optional[str]: def parent_seed(self) -> Optional[str]:
"""Return the decrypted parent seed if set.""" """Return the decrypted parent seed if set."""
@@ -1473,7 +1497,7 @@ class PasswordManager:
except FileNotFoundError: except FileNotFoundError:
print( print(
colored( colored(
"Checksum file missing. Run scripts/update_checksum.py to generate it.", "Checksum file missing. Run scripts/update_checksum.py or choose 'Generate Script Checksum' in Settings.",
"yellow", "yellow",
) )
) )
@@ -1495,6 +1519,26 @@ class PasswordManager:
logging.error(f"Error during checksum verification: {e}", exc_info=True) logging.error(f"Error during checksum verification: {e}", exc_info=True)
print(colored(f"Error: Failed to verify checksum: {e}", "red")) print(colored(f"Error: Failed to verify checksum: {e}", "red"))
def handle_update_script_checksum(self) -> None:
"""Generate a new checksum for the manager script."""
if not confirm_action("Generate new script checksum? (Y/N): "):
print(colored("Operation cancelled.", "yellow"))
return
try:
script_path = Path(__file__).resolve()
if update_checksum_file(str(script_path), str(SCRIPT_CHECKSUM_FILE)):
print(
colored(
f"Checksum updated at '{SCRIPT_CHECKSUM_FILE}'.",
"green",
)
)
else:
print(colored("Failed to update checksum.", "red"))
except Exception as e:
logging.error(f"Error updating checksum: {e}", exc_info=True)
print(colored(f"Error: Failed to update checksum: {e}", "red"))
def get_encrypted_data(self) -> Optional[bytes]: def get_encrypted_data(self) -> Optional[bytes]:
""" """
Retrieves the encrypted password index data. Retrieves the encrypted password index data.
@@ -1941,13 +1985,33 @@ class PasswordManager:
# Nostr sync info # Nostr sync info
manifest = getattr(self.nostr_client, "current_manifest", None) manifest = getattr(self.nostr_client, "current_manifest", None)
if manifest is None:
try:
result = asyncio.run(self.nostr_client.fetch_latest_snapshot())
if result:
manifest, _ = result
except Exception:
manifest = None
if manifest is not None: if manifest is not None:
stats["chunk_count"] = len(manifest.chunks) stats["chunk_count"] = len(manifest.chunks)
stats["delta_since"] = manifest.delta_since stats["delta_since"] = manifest.delta_since
delta_count = 0
if manifest.delta_since:
try:
version = int(manifest.delta_since)
except ValueError:
version = 0
try:
deltas = asyncio.run(self.nostr_client.fetch_deltas_since(version))
delta_count = len(deltas)
except Exception:
delta_count = 0
stats["pending_deltas"] = delta_count
else: else:
stats["chunk_count"] = 0 stats["chunk_count"] = 0
stats["delta_since"] = None stats["delta_since"] = None
stats["pending_deltas"] = len(getattr(self.nostr_client, "_delta_events", [])) stats["pending_deltas"] = 0
return stats return stats

View File

@@ -21,6 +21,7 @@ def make_pm(search_results, entry=None, totp_code="123456"):
nostr_client=SimpleNamespace(close_client_pool=lambda: None), nostr_client=SimpleNamespace(close_client_pool=lambda: None),
parent_seed="seed", parent_seed="seed",
inactivity_timeout=1, inactivity_timeout=1,
clipboard_clear_delay=45,
) )
return pm return pm
@@ -58,7 +59,9 @@ def test_totp_command(monkeypatch, capsys):
monkeypatch.setattr(main, "configure_logging", lambda: None) monkeypatch.setattr(main, "configure_logging", lambda: None)
monkeypatch.setattr(main, "initialize_app", lambda: None) monkeypatch.setattr(main, "initialize_app", lambda: None)
monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None)
monkeypatch.setattr(main.pyperclip, "copy", lambda v: called.setdefault("val", v)) monkeypatch.setattr(
main, "copy_to_clipboard", lambda v, d: called.setdefault("val", v)
)
rc = main.main(["totp", "ex"]) rc = main.main(["totp", "ex"])
assert rc == 0 assert rc == 0
out = capsys.readouterr().out out = capsys.readouterr().out

View File

@@ -57,7 +57,7 @@ def test_handle_verify_checksum_missing(monkeypatch, tmp_path, capsys):
monkeypatch.setattr("password_manager.manager.verify_checksum", raise_missing) monkeypatch.setattr("password_manager.manager.verify_checksum", raise_missing)
pm.handle_verify_checksum() pm.handle_verify_checksum()
out = capsys.readouterr().out.lower() out = capsys.readouterr().out.lower()
assert "update_checksum.py" in out assert "generate script checksum" in out
def test_backup_and_restore_database(monkeypatch, capsys): def test_backup_and_restore_database(monkeypatch, capsys):

View File

@@ -93,7 +93,7 @@ def test_settings_menu_additional_backup(monkeypatch):
tmp_path = Path(tmpdir) tmp_path = Path(tmpdir)
pm, cfg_mgr, fp_mgr = setup_pm(tmp_path, monkeypatch) pm, cfg_mgr, fp_mgr = setup_pm(tmp_path, monkeypatch)
inputs = iter(["9", "14"]) inputs = iter(["10", "15"])
with patch("main.handle_set_additional_backup_location") as handler: with patch("main.handle_set_additional_backup_location") as handler:
with patch("builtins.input", side_effect=lambda *_: next(inputs)): with patch("builtins.input", side_effect=lambda *_: next(inputs)):
main.handle_settings(pm) main.handle_settings(pm)

View File

@@ -21,6 +21,8 @@ try:
verify_checksum, verify_checksum,
json_checksum, json_checksum,
canonical_json_dumps, canonical_json_dumps,
initialize_checksum,
update_checksum_file,
) )
from .password_prompt import prompt_for_password from .password_prompt import prompt_for_password
from .input_utils import timed_input from .input_utils import timed_input
@@ -45,6 +47,8 @@ __all__ = [
"verify_checksum", "verify_checksum",
"json_checksum", "json_checksum",
"canonical_json_dumps", "canonical_json_dumps",
"initialize_checksum",
"update_checksum_file",
"exclusive_lock", "exclusive_lock",
"shared_lock", "shared_lock",
"prompt_for_password", "prompt_for_password",

View File

@@ -198,3 +198,29 @@ def initialize_checksum(file_path: str, checksum_file_path: str) -> bool:
) )
) )
return False return False
def update_checksum_file(file_path: str, checksum_file_path: str) -> bool:
"""Update ``checksum_file_path`` with the SHA-256 checksum of ``file_path``."""
checksum = calculate_checksum(file_path)
if checksum is None:
return False
try:
with open(checksum_file_path, "w") as f:
f.write(checksum)
logging.debug(
f"Updated checksum for '{file_path}' to '{checksum}' at '{checksum_file_path}'."
)
return True
except Exception as exc:
logging.error(
f"Failed to update checksum file '{checksum_file_path}': {exc}",
exc_info=True,
)
print(
colored(
f"Error: Failed to update checksum file '{checksum_file_path}': {exc}",
"red",
)
)
return False

View File

@@ -1,10 +1,40 @@
import threading import threading
import logging
import subprocess
import shutil
import sys
import pyperclip import pyperclip
logger = logging.getLogger(__name__)
def _ensure_clipboard() -> None:
"""Attempt to ensure a clipboard mechanism is available."""
try:
pyperclip.copy("")
except pyperclip.PyperclipException as exc:
if sys.platform.startswith("linux"):
if shutil.which("xclip") is None and shutil.which("xsel") is None:
apt = shutil.which("apt-get") or shutil.which("apt")
if apt:
try:
subprocess.run(
["sudo", apt, "install", "-y", "xclip"], check=True
)
pyperclip.copy("")
return
except Exception as install_exc: # pragma: no cover - system dep
logger.warning(
"Automatic xclip installation failed: %s", install_exc
)
raise exc
def copy_to_clipboard(text: str, timeout: int) -> None: def copy_to_clipboard(text: str, timeout: int) -> None:
"""Copy text to the clipboard and clear after timeout seconds if unchanged.""" """Copy text to the clipboard and clear after timeout seconds if unchanged."""
_ensure_clipboard()
pyperclip.copy(text) pyperclip.copy(text)
def clear_clipboard() -> None: def clear_clipboard() -> None: