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
# 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.
- **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.
- **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.
- **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.
@@ -110,6 +110,16 @@ pip install --upgrade pip
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
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.
* Choose `4` to verify the script checksum.
* Choose `5` to back up the parent seed.
* Select `6` to export the database to an encrypted file.
* Choose `7` to import a database from a backup file.
* 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 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.
* Select `5` to generate a new script checksum.
* Choose `6` to back up the parent seed.
* Select `7` to export the database to an encrypted file.
* Choose `8` to import a database from a backup file.
* Select `9` to export all 2FA codes.
* Choose `10` to set an additional backup location.
* Select `11` to change the inactivity timeout.
* Choose `12` to lock the vault and require re-entry of your password.
* Select `13` 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

View File

@@ -7,7 +7,7 @@ SRC_DIR = PROJECT_ROOT / "src"
if str(SRC_DIR) not in sys.path:
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
@@ -15,11 +15,8 @@ def main() -> None:
"""Calculate checksum for the main script and write it to SCRIPT_CHECKSUM_FILE."""
initialize_app()
script_path = SRC_DIR / "password_manager" / "manager.py"
checksum = calculate_checksum(str(script_path))
if checksum is None:
raise SystemExit(f"Failed to calculate checksum for {script_path}")
SCRIPT_CHECKSUM_FILE.write_text(checksum)
if not update_checksum_file(str(script_path), str(SCRIPT_CHECKSUM_FILE)):
raise SystemExit(f"Failed to update checksum for {script_path}")
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 constants import INACTIVITY_TIMEOUT, initialize_app
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
import pyperclip
colorama_init()
@@ -618,16 +617,17 @@ def handle_settings(password_manager: PasswordManager) -> None:
print("2. Nostr")
print("3. Change password")
print("4. Verify Script Checksum")
print("5. Backup Parent Seed")
print("6. Export database")
print("7. Import database")
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. Toggle Secret Mode")
print("14. Back")
print("5. Generate Script Checksum")
print("6. Backup Parent Seed")
print("7. Export database")
print("8. Import database")
print("9. Export 2FA codes")
print("10. Set additional backup location")
print("11. Set inactivity timeout")
print("12. Lock Vault")
print("13. Stats")
print("14. Toggle Secret Mode")
print("15. Back")
choice = input("Select an option: ").strip()
if choice == "1":
handle_profiles_menu(password_manager)
@@ -638,28 +638,30 @@ def handle_settings(password_manager: PasswordManager) -> None:
elif choice == "4":
password_manager.handle_verify_checksum()
elif choice == "5":
password_manager.handle_backup_reveal_parent_seed()
password_manager.handle_update_script_checksum()
elif choice == "6":
password_manager.handle_export_database()
password_manager.handle_backup_reveal_parent_seed()
elif choice == "7":
password_manager.handle_export_database()
elif choice == "8":
path = input("Enter path to backup file: ").strip()
if path:
password_manager.handle_import_database(Path(path))
elif choice == "8":
password_manager.handle_export_totp_codes()
elif choice == "9":
handle_set_additional_backup_location(password_manager)
password_manager.handle_export_totp_codes()
elif choice == "10":
handle_set_inactivity_timeout(password_manager)
handle_set_additional_backup_location(password_manager)
elif choice == "11":
handle_set_inactivity_timeout(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_toggle_secret_mode(password_manager)
handle_display_stats(password_manager)
elif choice == "14":
handle_toggle_secret_mode(password_manager)
elif choice == "15":
break
else:
print(colored("Invalid choice.", "red"))
@@ -852,7 +854,7 @@ def main(argv: list[str] | None = None) -> int:
)
print(code)
try:
pyperclip.copy(code)
copy_to_clipboard(code, password_manager.clipboard_clear_delay)
print(colored("Code copied to clipboard", "green"))
except Exception as exc:
logging.warning(f"Clipboard copy failed: {exc}")

View File

@@ -34,7 +34,13 @@ from utils.key_derivation import (
derive_index_key,
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 (
prompt_for_password,
prompt_existing_password,
@@ -89,6 +95,7 @@ class PasswordManager:
def __init__(self) -> None:
"""Initialize the PasswordManager."""
initialize_app()
self.ensure_script_checksum()
self.encryption_mode: EncryptionMode = EncryptionMode.SEED_ONLY
self.encryption_manager: Optional[EncryptionManager] = None
self.entry_manager: Optional[EntryManager] = None
@@ -119,6 +126,23 @@ class PasswordManager:
# Set the current fingerprint directory
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
def parent_seed(self) -> Optional[str]:
"""Return the decrypted parent seed if set."""
@@ -1473,7 +1497,7 @@ class PasswordManager:
except FileNotFoundError:
print(
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",
)
)
@@ -1495,6 +1519,26 @@ class PasswordManager:
logging.error(f"Error during checksum verification: {e}", exc_info=True)
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]:
"""
Retrieves the encrypted password index data.
@@ -1941,13 +1985,33 @@ class PasswordManager:
# Nostr sync info
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:
stats["chunk_count"] = len(manifest.chunks)
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:
stats["chunk_count"] = 0
stats["delta_since"] = None
stats["pending_deltas"] = len(getattr(self.nostr_client, "_delta_events", []))
stats["pending_deltas"] = 0
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),
parent_seed="seed",
inactivity_timeout=1,
clipboard_clear_delay=45,
)
return pm
@@ -58,7 +59,9 @@ def test_totp_command(monkeypatch, capsys):
monkeypatch.setattr(main, "configure_logging", lambda: None)
monkeypatch.setattr(main, "initialize_app", lambda: 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"])
assert rc == 0
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)
pm.handle_verify_checksum()
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):

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", "14"])
inputs = iter(["10", "15"])
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

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

View File

@@ -198,3 +198,29 @@ def initialize_checksum(file_path: str, checksum_file_path: str) -> bool:
)
)
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 logging
import subprocess
import shutil
import sys
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:
"""Copy text to the clipboard and clear after timeout seconds if unchanged."""
_ensure_clipboard()
pyperclip.copy(text)
def clear_clipboard() -> None: