mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-09 07:48:57 +00:00
3
.gitignore
vendored
3
.gitignore
vendored
@@ -31,4 +31,5 @@ Thumbs.db
|
||||
coverage.xml
|
||||
|
||||
# Other
|
||||
.hypothesis
|
||||
.hypothesis
|
||||
totp_export.json.enc
|
||||
|
33
README.md
33
README.md
@@ -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 50 KB 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
|
||||
|
||||
|
@@ -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}")
|
||||
|
||||
|
||||
|
46
src/main.py
46
src/main.py
@@ -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}")
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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):
|
||||
|
@@ -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)
|
||||
|
@@ -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",
|
||||
|
@@ -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
|
||||
|
@@ -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:
|
||||
|
Reference in New Issue
Block a user