From 492025339f65731e95fdc977ad95f13e302a87f4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 1 Jul 2025 19:26:57 -0400 Subject: [PATCH 01/13] Fix dark mode toggle placement --- landing/style.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/landing/style.css b/landing/style.css index 673d723..bbc7410 100644 --- a/landing/style.css +++ b/landing/style.css @@ -90,7 +90,7 @@ body.dark-mode { /* Dark Mode Toggle */ .dark-mode-toggle { position: fixed; - top: 20px; + top: 12px; right: 20px; z-index: 1000; } @@ -832,8 +832,8 @@ footer .social-media a:focus { @media screen and (max-width: 768px) { .navbar .container { - flex-direction: column; - align-items: flex-start; + flex-direction: row; + align-items: center; } .nav-links { From dfd7867fd153a80a0f7ed5921ba1636e95c0d5c3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 1 Jul 2025 20:22:27 -0400 Subject: [PATCH 02/13] Add portable backup export/import --- src/password_manager/portable_backup.py | 140 ++++++++++++++++++++++++ src/tests/test_portable_backup.py | 116 ++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 src/password_manager/portable_backup.py create mode 100644 src/tests/test_portable_backup.py diff --git a/src/password_manager/portable_backup.py b/src/password_manager/portable_backup.py new file mode 100644 index 0000000..d424cf9 --- /dev/null +++ b/src/password_manager/portable_backup.py @@ -0,0 +1,140 @@ +# portable_backup.py +"""Export and import encrypted profile backups.""" + +from __future__ import annotations + +import base64 +import json +import hashlib +import logging +import os +import time +from enum import Enum +from pathlib import Path + +from password_manager.vault import Vault +from password_manager.backup import BackupManager +from nostr.client import NostrClient +from utils.key_derivation import ( + derive_index_key, + EncryptionMode, + DEFAULT_ENCRYPTION_MODE, +) +from utils.password_prompt import prompt_existing_password +from password_manager.encryption import EncryptionManager + +logger = logging.getLogger(__name__) + +FORMAT_VERSION = 1 +EXPORT_NAME_TEMPLATE = "seedpass_export_{ts}.json" + + +class PortableMode(Enum): + """Encryption mode for portable exports.""" + + SEED_ONLY = EncryptionMode.SEED_ONLY.value + SEED_PLUS_PW = EncryptionMode.SEED_PLUS_PW.value + PW_ONLY = EncryptionMode.PW_ONLY.value + + +def _derive_export_key( + seed: str, + mode: PortableMode, + password: str | None = None, +) -> bytes: + """Derive the Fernet key for the export payload.""" + + enc_mode = EncryptionMode(mode.value) + return derive_index_key(seed, password, enc_mode) + + +def export_backup( + vault: Vault, + backup_manager: BackupManager, + mode: PortableMode = PortableMode.SEED_ONLY, + dest_path: Path | None = None, + *, + publish: bool = False, +) -> Path: + """Export the current vault state to a portable encrypted file.""" + + if dest_path is None: + ts = int(time.time()) + dest_dir = vault.fingerprint_dir / "exports" + dest_dir.mkdir(parents=True, exist_ok=True) + dest_path = dest_dir / EXPORT_NAME_TEMPLATE.format(ts=ts) + + index_data = vault.load_index() + seed = vault.encryption_manager.decrypt_parent_seed() + password = None + if mode in (PortableMode.SEED_PLUS_PW, PortableMode.PW_ONLY): + password = prompt_existing_password("Enter your master password: ") + + key = _derive_export_key(seed, mode, password) + enc_mgr = EncryptionManager(key, vault.fingerprint_dir) + payload_bytes = enc_mgr.encrypt_data( + json.dumps(index_data, indent=4).encode("utf-8") + ) + checksum = hashlib.sha256(payload_bytes).hexdigest() + + wrapper = { + "format_version": FORMAT_VERSION, + "created_at": int(time.time()), + "fingerprint": vault.fingerprint_dir.name, + "encryption_mode": mode.value, + "cipher": "fernet", + "checksum": checksum, + "payload": base64.b64encode(payload_bytes).decode("utf-8"), + } + + json_bytes = json.dumps(wrapper, indent=2).encode("utf-8") + dest_path.write_bytes(json_bytes) + os.chmod(dest_path, 0o600) + + if publish: + encrypted = vault.encryption_manager.encrypt_data(json_bytes) + enc_file = dest_path.with_suffix(dest_path.suffix + ".enc") + enc_file.write_bytes(encrypted) + os.chmod(enc_file, 0o600) + try: + client = NostrClient(vault.encryption_manager, vault.fingerprint_dir.name) + client.publish_json_to_nostr(encrypted) + except Exception: + logger.error("Failed to publish backup via Nostr", exc_info=True) + + return dest_path + + +def import_backup( + vault: Vault, + backup_manager: BackupManager, + path: Path, +) -> None: + """Import a portable backup file and replace the current index.""" + + raw = Path(path).read_bytes() + if path.suffix.endswith(".enc"): + raw = vault.encryption_manager.decrypt_data(raw) + + wrapper = json.loads(raw.decode("utf-8")) + if wrapper.get("format_version") != FORMAT_VERSION: + raise ValueError("Unsupported backup format") + + mode = PortableMode(wrapper.get("encryption_mode", PortableMode.SEED_ONLY.value)) + payload = base64.b64decode(wrapper["payload"]) + checksum = hashlib.sha256(payload).hexdigest() + if checksum != wrapper.get("checksum"): + raise ValueError("Checksum mismatch") + + seed = vault.encryption_manager.decrypt_parent_seed() + password = None + if mode in (PortableMode.SEED_PLUS_PW, PortableMode.PW_ONLY): + password = prompt_existing_password("Enter your master password: ") + + key = _derive_export_key(seed, mode, password) + enc_mgr = EncryptionManager(key, vault.fingerprint_dir) + index_bytes = enc_mgr.decrypt_data(payload) + index = json.loads(index_bytes.decode("utf-8")) + + backup_manager.create_backup() + vault.save_index(index) diff --git a/src/tests/test_portable_backup.py b/src/tests/test_portable_backup.py new file mode 100644 index 0000000..835b981 --- /dev/null +++ b/src/tests/test_portable_backup.py @@ -0,0 +1,116 @@ +import json +import base64 +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest +import sys + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.encryption import EncryptionManager +from password_manager.vault import Vault +from password_manager.backup import BackupManager +from password_manager.portable_backup import ( + PortableMode, + export_backup, + import_backup, +) +from utils.key_derivation import derive_index_key, EncryptionMode + + +SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" +PASSWORD = "passw0rd" + + +def setup_vault(tmp: Path, mode: EncryptionMode = EncryptionMode.SEED_ONLY): + index_key = derive_index_key(SEED, PASSWORD, mode) + enc_mgr = EncryptionManager(index_key, tmp) + enc_mgr.encrypt_parent_seed(SEED) + vault = Vault(enc_mgr, tmp) + backup = BackupManager(tmp) + return vault, backup + + +def test_round_trip_across_modes(monkeypatch): + for pmode in [ + PortableMode.SEED_ONLY, + PortableMode.SEED_PLUS_PW, + PortableMode.PW_ONLY, + ]: + with TemporaryDirectory() as td: + tmp = Path(td) + vault, backup = setup_vault(tmp) + data = {"pw": 1} + vault.save_index(data) + + monkeypatch.setattr( + "password_manager.portable_backup.prompt_existing_password", + lambda *_a, **_k: PASSWORD, + ) + + path = export_backup(vault, backup, pmode) + assert path.exists() + + vault.save_index({"pw": 0}) + import_backup(vault, backup, path) + assert vault.load_index() == data + + +def test_corruption_detection(monkeypatch): + with TemporaryDirectory() as td: + tmp = Path(td) + vault, backup = setup_vault(tmp) + vault.save_index({"a": 1}) + + monkeypatch.setattr( + "password_manager.portable_backup.prompt_existing_password", + lambda *_a, **_k: PASSWORD, + ) + path = export_backup(vault, backup, PortableMode.SEED_ONLY) + + content = json.loads(path.read_text()) + payload = base64.b64decode(content["payload"]) + payload = b"x" + payload[1:] + content["payload"] = base64.b64encode(payload).decode() + path.write_text(json.dumps(content)) + + with pytest.raises(ValueError): + import_backup(vault, backup, path) + + +def test_incorrect_credentials(monkeypatch): + with TemporaryDirectory() as td: + tmp = Path(td) + vault, backup = setup_vault(tmp) + vault.save_index({"a": 2}) + + monkeypatch.setattr( + "password_manager.portable_backup.prompt_existing_password", + lambda *_a, **_k: PASSWORD, + ) + path = export_backup(vault, backup, PortableMode.SEED_PLUS_PW) + + monkeypatch.setattr( + "password_manager.portable_backup.prompt_existing_password", + lambda *_a, **_k: "wrong", + ) + with pytest.raises(Exception): + import_backup(vault, backup, path) + + +def test_import_over_existing(monkeypatch): + with TemporaryDirectory() as td: + tmp = Path(td) + vault, backup = setup_vault(tmp) + vault.save_index({"v": 1}) + + monkeypatch.setattr( + "password_manager.portable_backup.prompt_existing_password", + lambda *_a, **_k: PASSWORD, + ) + path = export_backup(vault, backup, PortableMode.SEED_ONLY) + + vault.save_index({"v": 2}) + import_backup(vault, backup, path) + assert vault.load_index() == {"v": 1} From 5b2c239c21e396ec6e26748453b47b9242b5dcc1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 1 Jul 2025 21:19:58 -0400 Subject: [PATCH 03/13] Add schema migrations for index --- src/password_manager/entry_management.py | 5 +-- src/password_manager/migrations.py | 43 ++++++++++++++++++++++++ src/password_manager/vault.py | 13 +++++-- src/tests/test_backup_restore.py | 4 +-- src/tests/test_migrations.py | 34 +++++++++++++++++++ src/tests/test_portable_backup.py | 5 +-- 6 files changed, 96 insertions(+), 8 deletions(-) create mode 100644 src/password_manager/migrations.py create mode 100644 src/tests/test_migrations.py diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 346696f..26f4008 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -27,6 +27,7 @@ from typing import Optional, Tuple, Dict, Any, List from pathlib import Path from termcolor import colored +from password_manager.migrations import LATEST_VERSION from password_manager.vault import Vault from utils.file_lock import exclusive_lock @@ -61,12 +62,12 @@ class EntryManager: return data except Exception as e: logger.error(f"Failed to load index: {e}") - return {"passwords": {}} + return {"schema_version": LATEST_VERSION, "passwords": {}} else: logger.info( f"Index file '{self.index_file}' not found. Initializing new password database." ) - return {"passwords": {}} + return {"schema_version": LATEST_VERSION, "passwords": {}} def _save_index(self, data: Dict[str, Any]) -> None: try: diff --git a/src/password_manager/migrations.py b/src/password_manager/migrations.py new file mode 100644 index 0000000..e1e6843 --- /dev/null +++ b/src/password_manager/migrations.py @@ -0,0 +1,43 @@ +"""Schema migration helpers for password index files.""" + +from __future__ import annotations + +from typing import Callable, Dict + +MIGRATIONS: Dict[int, Callable[[dict], dict]] = {} + + +def migration(from_ver: int) -> Callable[[Callable[[dict], dict]], Callable[[dict], dict]]: + """Register a migration function from *from_ver* to *from_ver* + 1.""" + + def decorator(func: Callable[[dict], dict]) -> Callable[[dict], dict]: + MIGRATIONS[from_ver] = func + return func + + return decorator + + +@migration(0) +def _v0_to_v1(data: dict) -> dict: + """Inject schema_version field for initial upgrade.""" + data["schema_version"] = 1 + return data + + +LATEST_VERSION = 1 + + +def apply_migrations(data: dict) -> dict: + """Upgrade *data* in-place to the latest schema version.""" + current = data.get("schema_version", 0) + if current > LATEST_VERSION: + raise ValueError(f"Unsupported schema version {current}") + + while current < LATEST_VERSION: + migrate = MIGRATIONS.get(current) + if migrate is None: + raise ValueError(f"No migration available from version {current}") + data = migrate(data) + current = data.get("schema_version", current + 1) + + return data diff --git a/src/password_manager/vault.py b/src/password_manager/vault.py index 08561de..a3cfeef 100644 --- a/src/password_manager/vault.py +++ b/src/password_manager/vault.py @@ -29,8 +29,17 @@ class Vault: # ----- Password index helpers ----- def load_index(self) -> dict: - """Return decrypted password index data as a dict.""" - return self.encryption_manager.load_json_data(self.index_file) + """Return decrypted password index data as a dict, applying migrations.""" + data = self.encryption_manager.load_json_data(self.index_file) + from .migrations import apply_migrations, LATEST_VERSION + + version = data.get("schema_version", 0) + if version > LATEST_VERSION: + raise ValueError( + f"File schema version {version} is newer than supported {LATEST_VERSION}" + ) + data = apply_migrations(data) + return data def save_index(self, data: dict) -> None: """Encrypt and write password index.""" diff --git a/src/tests/test_backup_restore.py b/src/tests/test_backup_restore.py index 56d9329..3abe76a 100644 --- a/src/tests/test_backup_restore.py +++ b/src/tests/test_backup_restore.py @@ -45,11 +45,11 @@ def test_backup_restore_workflow(monkeypatch): vault.save_index({"passwords": {"temp": {}}}) backup_mgr.restore_latest_backup() - assert vault.load_index() == data2 + assert vault.load_index()["passwords"] == data2["passwords"] vault.save_index({"passwords": {}}) backup_mgr.restore_backup_by_timestamp(1111) - assert vault.load_index() == data1 + assert vault.load_index()["passwords"] == data1["passwords"] backup1.unlink() current = vault.load_index() diff --git a/src/tests/test_migrations.py b/src/tests/test_migrations.py new file mode 100644 index 0000000..fa14ef2 --- /dev/null +++ b/src/tests/test_migrations.py @@ -0,0 +1,34 @@ +import sys +from pathlib import Path +import pytest +from cryptography.fernet import Fernet + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.encryption import EncryptionManager +from password_manager.vault import Vault +from password_manager.migrations import LATEST_VERSION + + +def setup(tmp_path: Path): + key = Fernet.generate_key() + enc_mgr = EncryptionManager(key, tmp_path) + vault = Vault(enc_mgr, tmp_path) + return enc_mgr, vault + + +def test_migrate_v0_to_v1(tmp_path: Path): + enc_mgr, vault = setup(tmp_path) + legacy = {"passwords": {"0": {"website": "a", "length": 8}}} + enc_mgr.save_json_data(legacy) + data = vault.load_index() + assert data["schema_version"] == LATEST_VERSION + assert data["passwords"] == legacy["passwords"] + + +def test_error_on_future_version(tmp_path: Path): + enc_mgr, vault = setup(tmp_path) + future = {"schema_version": LATEST_VERSION + 1, "passwords": {}} + enc_mgr.save_json_data(future) + with pytest.raises(ValueError): + vault.load_index() diff --git a/src/tests/test_portable_backup.py b/src/tests/test_portable_backup.py index 835b981..6b9c665 100644 --- a/src/tests/test_portable_backup.py +++ b/src/tests/test_portable_backup.py @@ -54,7 +54,7 @@ def test_round_trip_across_modes(monkeypatch): vault.save_index({"pw": 0}) import_backup(vault, backup, path) - assert vault.load_index() == data + assert vault.load_index()["pw"] == data["pw"] def test_corruption_detection(monkeypatch): @@ -113,4 +113,5 @@ def test_import_over_existing(monkeypatch): vault.save_index({"v": 2}) import_backup(vault, backup, path) - assert vault.load_index() == {"v": 1} + loaded = vault.load_index() + assert loaded["v"] == 1 From f67b73f9147f0edf0537a254a5d7a74f574aeafa Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 1 Jul 2025 21:32:34 -0400 Subject: [PATCH 04/13] Add portable export/import features --- src/main.py | 37 +++++++++++++-- src/password_manager/manager.py | 34 ++++++++++++++ .../test_cli_portable_backup_commands.py | 47 +++++++++++++++++++ 3 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 src/tests/test_cli_portable_backup_commands.py diff --git a/src/main.py b/src/main.py index 566ef2a..0c7a03b 100644 --- a/src/main.py +++ b/src/main.py @@ -13,6 +13,7 @@ from termcolor import colored import traceback from password_manager.manager import PasswordManager +from password_manager.portable_backup import PortableMode from nostr.client import NostrClient from constants import INACTIVITY_TIMEOUT from utils.key_derivation import EncryptionMode @@ -457,8 +458,10 @@ def handle_settings(password_manager: PasswordManager) -> None: print("3. Change password") print("4. Verify Script Checksum") print("5. Backup Parent Seed") - print("6. Lock Vault") - print("7. Back") + print("6. Export database") + print("7. Import database") + print("8. Lock Vault") + print("9. Back") choice = input("Select an option: ").strip() if choice == "1": handle_profiles_menu(password_manager) @@ -471,10 +474,16 @@ def handle_settings(password_manager: PasswordManager) -> None: elif choice == "5": password_manager.handle_backup_reveal_parent_seed() elif choice == "6": + password_manager.handle_export_database() + elif choice == "7": + path = input("Enter path to backup file: ").strip() + if path: + password_manager.handle_import_database(Path(path)) + elif choice == "8": password_manager.lock_vault() print(colored("Vault locked. Please re-enter your password.", "yellow")) password_manager.unlock_vault() - elif choice == "7": + elif choice == "9": break else: print(colored("Invalid choice.", "red")) @@ -565,11 +574,25 @@ if __name__ == "__main__": # Load config from disk and parse command-line arguments cfg = load_global_config() parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command") + parser.add_argument( "--encryption-mode", choices=[m.value for m in EncryptionMode], help="Select encryption mode", ) + + exp = sub.add_parser("export") + exp.add_argument( + "--mode", + choices=[m.value for m in PortableMode], + default=PortableMode.SEED_ONLY.value, + ) + exp.add_argument("--file") + + imp = sub.add_parser("import") + imp.add_argument("--file") + args = parser.parse_args() mode_value = cfg.get("encryption_mode", EncryptionMode.SEED_ONLY.value) @@ -591,6 +614,14 @@ if __name__ == "__main__": print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red")) sys.exit(1) + if args.command == "export": + mode = PortableMode(args.mode) + password_manager.handle_export_database(mode, Path(args.file)) + sys.exit(0) + elif args.command == "import": + password_manager.handle_import_database(Path(args.file)) + sys.exit(0) + # Register signal handlers for graceful shutdown def signal_handler(sig, frame): """ diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 1f6f5dc..e2e6b52 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -24,6 +24,11 @@ from password_manager.entry_management import EntryManager from password_manager.password_generation import PasswordGenerator from password_manager.backup import BackupManager from password_manager.vault import Vault +from password_manager.portable_backup import ( + export_backup, + import_backup, + PortableMode, +) from utils.key_derivation import ( derive_key_from_parent_seed, derive_key_from_password, @@ -1137,6 +1142,35 @@ class PasswordManager: logging.error(f"Failed to restore backup: {e}", exc_info=True) print(colored(f"Error: Failed to restore backup: {e}", "red")) + def handle_export_database( + self, + mode: "PortableMode" = PortableMode.SEED_ONLY, + dest: Path | None = None, + ) -> Path | None: + """Export the current database to an encrypted portable file.""" + try: + path = export_backup( + self.vault, + self.backup_manager, + mode, + dest, + ) + print(colored(f"Database exported to '{path}'.", "green")) + return path + except Exception as e: + logging.error(f"Failed to export database: {e}", exc_info=True) + print(colored(f"Error: Failed to export database: {e}", "red")) + return None + + def handle_import_database(self, src: Path) -> None: + """Import a portable database file, replacing the current index.""" + try: + import_backup(self.vault, self.backup_manager, src) + print(colored("Database imported successfully.", "green")) + except Exception as e: + logging.error(f"Failed to import database: {e}", exc_info=True) + print(colored(f"Error: Failed to import database: {e}", "red")) + def handle_backup_reveal_parent_seed(self) -> None: """ Handles the backup and reveal of the parent seed. diff --git a/src/tests/test_cli_portable_backup_commands.py b/src/tests/test_cli_portable_backup_commands.py new file mode 100644 index 0000000..fa6c86c --- /dev/null +++ b/src/tests/test_cli_portable_backup_commands.py @@ -0,0 +1,47 @@ +import sys +from pathlib import Path +import runpy + +import pytest + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +import main +from password_manager.portable_backup import PortableMode +from password_manager.manager import PasswordManager + + +def _run(argv, monkeypatch): + monkeypatch.setattr(sys, "argv", ["seedpass"] + argv) + monkeypatch.setattr(main, "load_global_config", lambda: {}) + called = {} + + def fake_init(self, encryption_mode): + called["init"] = True + + def fake_export(self, mode, dest): + called["export"] = (mode, Path(dest)) + + def fake_import(self, src): + called["import"] = Path(src) + + monkeypatch.setattr(PasswordManager, "__init__", fake_init) + monkeypatch.setattr(PasswordManager, "handle_export_database", fake_export) + monkeypatch.setattr(PasswordManager, "handle_import_database", fake_import) + + with pytest.raises(SystemExit): + runpy.run_module("main", run_name="__main__") + + return called + + +def test_export_command_invokes_handler(monkeypatch): + called = _run(["export", "--mode", "pw-only", "--file", "out.json"], monkeypatch) + assert called["export"] == (PortableMode.PW_ONLY, Path("out.json")) + assert "import" not in called + + +def test_import_command_invokes_handler(monkeypatch): + called = _run(["import", "--file", "backup.json"], monkeypatch) + assert called["import"] == Path("backup.json") + assert "export" not in called From c10e2380e5f628d38e39c68513592da055cc2bf2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 1 Jul 2025 21:43:52 -0400 Subject: [PATCH 05/13] Add checksum verification for portable backups --- src/password_manager/portable_backup.py | 17 +++++++------ src/tests/test_checksum_utils.py | 9 +++++++ src/tests/test_portable_backup.py | 33 ++++++++++++++++++++++++- src/utils/__init__.py | 9 ++++++- src/utils/checksum.py | 14 ++++++++++- 5 files changed, 71 insertions(+), 11 deletions(-) diff --git a/src/password_manager/portable_backup.py b/src/password_manager/portable_backup.py index d424cf9..b027545 100644 --- a/src/password_manager/portable_backup.py +++ b/src/password_manager/portable_backup.py @@ -5,7 +5,6 @@ from __future__ import annotations import base64 import json -import hashlib import logging import os import time @@ -22,6 +21,7 @@ from utils.key_derivation import ( ) from utils.password_prompt import prompt_existing_password from password_manager.encryption import EncryptionManager +from utils.checksum import json_checksum, canonical_json_dumps logger = logging.getLogger(__name__) @@ -72,10 +72,10 @@ def export_backup( key = _derive_export_key(seed, mode, password) enc_mgr = EncryptionManager(key, vault.fingerprint_dir) - payload_bytes = enc_mgr.encrypt_data( - json.dumps(index_data, indent=4).encode("utf-8") - ) - checksum = hashlib.sha256(payload_bytes).hexdigest() + + canonical = canonical_json_dumps(index_data) + payload_bytes = enc_mgr.encrypt_data(canonical.encode("utf-8")) + checksum = json_checksum(index_data) wrapper = { "format_version": FORMAT_VERSION, @@ -122,9 +122,6 @@ def import_backup( mode = PortableMode(wrapper.get("encryption_mode", PortableMode.SEED_ONLY.value)) payload = base64.b64decode(wrapper["payload"]) - checksum = hashlib.sha256(payload).hexdigest() - if checksum != wrapper.get("checksum"): - raise ValueError("Checksum mismatch") seed = vault.encryption_manager.decrypt_parent_seed() password = None @@ -136,5 +133,9 @@ def import_backup( index_bytes = enc_mgr.decrypt_data(payload) index = json.loads(index_bytes.decode("utf-8")) + checksum = json_checksum(index) + if checksum != wrapper.get("checksum"): + raise ValueError("Checksum mismatch") + backup_manager.create_backup() vault.save_index(index) diff --git a/src/tests/test_checksum_utils.py b/src/tests/test_checksum_utils.py index e30643d..816462a 100644 --- a/src/tests/test_checksum_utils.py +++ b/src/tests/test_checksum_utils.py @@ -1,9 +1,18 @@ import hashlib +import json from pathlib import Path from utils import checksum +def test_json_checksum(): + data = {"b": 1, "a": 2} + expected = hashlib.sha256( + json.dumps(data, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + assert checksum.json_checksum(data) == expected + + def test_calculate_checksum(tmp_path): file = tmp_path / "data.txt" content = "hello world" diff --git a/src/tests/test_portable_backup.py b/src/tests/test_portable_backup.py index 6b9c665..ba78dd1 100644 --- a/src/tests/test_portable_backup.py +++ b/src/tests/test_portable_backup.py @@ -57,6 +57,9 @@ def test_round_trip_across_modes(monkeypatch): assert vault.load_index()["pw"] == data["pw"] +from cryptography.fernet import InvalidToken + + def test_corruption_detection(monkeypatch): with TemporaryDirectory() as td: tmp = Path(td) @@ -75,7 +78,7 @@ def test_corruption_detection(monkeypatch): content["payload"] = base64.b64encode(payload).decode() path.write_text(json.dumps(content)) - with pytest.raises(ValueError): + with pytest.raises(InvalidToken): import_backup(vault, backup, path) @@ -115,3 +118,31 @@ def test_import_over_existing(monkeypatch): import_backup(vault, backup, path) loaded = vault.load_index() assert loaded["v"] == 1 + + +def test_checksum_mismatch_detection(monkeypatch): + with TemporaryDirectory() as td: + tmp = Path(td) + vault, backup = setup_vault(tmp) + vault.save_index({"a": 1}) + + monkeypatch.setattr( + "password_manager.portable_backup.prompt_existing_password", + lambda *_a, **_k: PASSWORD, + ) + + path = export_backup(vault, backup, PortableMode.SEED_ONLY) + + wrapper = json.loads(path.read_text()) + payload = base64.b64decode(wrapper["payload"]) + key = derive_index_key(SEED, PASSWORD, EncryptionMode.SEED_ONLY) + enc_mgr = EncryptionManager(key, tmp) + data = json.loads(enc_mgr.decrypt_data(payload).decode()) + data["a"] = 2 + mod_canon = json.dumps(data, sort_keys=True, separators=(",", ":")) + new_payload = enc_mgr.encrypt_data(mod_canon.encode()) + wrapper["payload"] = base64.b64encode(new_payload).decode() + path.write_text(json.dumps(wrapper)) + + with pytest.raises(ValueError): + import_backup(vault, backup, path) diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 6e21714..eb5ba69 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -14,7 +14,12 @@ try: EncryptionMode, DEFAULT_ENCRYPTION_MODE, ) - from .checksum import calculate_checksum, verify_checksum + from .checksum import ( + calculate_checksum, + verify_checksum, + json_checksum, + canonical_json_dumps, + ) from .password_prompt import prompt_for_password if logger.isEnabledFor(logging.DEBUG): @@ -31,6 +36,8 @@ __all__ = [ "DEFAULT_ENCRYPTION_MODE", "calculate_checksum", "verify_checksum", + "json_checksum", + "canonical_json_dumps", "exclusive_lock", "shared_lock", "prompt_for_password", diff --git a/src/utils/checksum.py b/src/utils/checksum.py index 60278f6..983e060 100644 --- a/src/utils/checksum.py +++ b/src/utils/checksum.py @@ -14,8 +14,9 @@ import hashlib import logging import sys import os +import json import traceback -from typing import Optional +from typing import Optional, Any from termcolor import colored @@ -25,6 +26,17 @@ from constants import APP_DIR, SCRIPT_CHECKSUM_FILE logger = logging.getLogger(__name__) +def canonical_json_dumps(data: Any) -> str: + """Serialize ``data`` into a canonical JSON string.""" + return json.dumps(data, sort_keys=True, separators=(",", ":")) + + +def json_checksum(data: Any) -> str: + """Return SHA-256 checksum of canonical JSON serialization of ``data``.""" + canon = canonical_json_dumps(data) + return hashlib.sha256(canon.encode("utf-8")).hexdigest() + + def calculate_checksum(file_path: str) -> Optional[str]: """ Calculates the SHA-256 checksum of the given file. From 2726c42cd9a743a2cd6ca731f640ce01b870ec03 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 1 Jul 2025 21:51:36 -0400 Subject: [PATCH 06/13] Document export/import modes and migrations --- README.md | 17 +++++++++++++++++ docs/advanced_cli.md | 29 ++++++++++++++++++++++++++++- docs/migrations.md | 25 +++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 docs/migrations.md diff --git a/README.md b/README.md index cadfdac..a48f348 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,23 @@ pip install --upgrade pip pip install -r src/requirements.txt ``` +## Quick Start + +After installing dependencies and activating your virtual environment, launch +SeedPass and create a backup: + +```bash +# Start the application +python src/main.py + +# Export your index using seed-only encryption +seedpass export --mode seed-only --file "~/seedpass_backup.json" + +# Later you can restore it +seedpass import --mode seed-only --file "~/seedpass_backup.json" +``` + + ## Usage After successfully installing the dependencies, you can run SeedPass using the following command: diff --git a/docs/advanced_cli.md b/docs/advanced_cli.md index 0e1ea08..7faade5 100644 --- a/docs/advanced_cli.md +++ b/docs/advanced_cli.md @@ -217,7 +217,25 @@ seedpass export --file "backup_passwords.json" ``` **Options:** -- `--file` (`-F`): The destination file path for the exported data. +- `--file` (`-F`): The destination file path for the exported data. If omitted, the export + is saved to the current profile's `exports` directory under `~/.seedpass//exports/`. +- `--mode` (`-M`): Choose the encryption mode for the exported file. Valid values are: + `seed-only`, `seed+pw`, `pw-only`, and `plaintext`. + +**Examples:** +```bash +# Standard encrypted export +seedpass export --mode seed-only --file "backup.json" +# Combine seed and master password for the export key +seedpass export --mode seed+pw --file "backup.json" +# Derive the key solely from your password +seedpass export --mode pw-only --file "backup.json" +# Plaintext JSON export (not recommended) +seedpass export --mode plaintext --file "backup.json" +``` + +**Warning:** The `plaintext` mode writes an unencrypted index to disk. Only use it +for debugging and delete the file immediately after use. --- @@ -237,6 +255,15 @@ seedpass import --file "backup_passwords.json" **Options:** - `--file` (`-F`): The source file path containing the password entries to import. +- `--mode` (`-M`): Indicates the encryption mode used when the file was exported. Accepted values are `seed-only`, `seed+pw`, `pw-only`, and `plaintext`. + +**Examples:** +```bash +# Import a standard encrypted backup +seedpass import --mode seed-only --file "backup.json" +# Import a backup that also used the master password +seedpass import --mode seed+pw --file "backup.json" +``` --- diff --git a/docs/migrations.md b/docs/migrations.md new file mode 100644 index 0000000..2c1cf46 --- /dev/null +++ b/docs/migrations.md @@ -0,0 +1,25 @@ +# Index Migrations + +SeedPass stores its password index in an encrypted JSON file. Each index contains +a `schema_version` field so the application knows how to upgrade older files. + +## How migrations work + +When the vault loads the index, `Vault.load_index()` checks the version and +applies migrations defined in `password_manager/migrations.py`. The +`apply_migrations()` function iterates through registered migrations until the +file reaches `LATEST_VERSION`. + +If an old file lacks `schema_version`, it is treated as version 0 and upgraded +to the latest format. Attempting to load an index from a future version will +raise an error. + +## Upgrading an index + +1. The JSON is decrypted and parsed. +2. `apply_migrations()` applies any necessary steps, such as injecting the + `schema_version` field on first upgrade. +3. After migration, the updated index is saved back to disk. + +This process happens automatically; users only need to open their vault to +upgrade older indices. From df407802f55649e6dcff709e37154ce08a5bfe98 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 1 Jul 2025 22:01:28 -0400 Subject: [PATCH 07/13] Add tests for index import/export across encryption modes --- src/tests/test_index_import_export.py | 55 +++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/tests/test_index_import_export.py diff --git a/src/tests/test_index_import_export.py b/src/tests/test_index_import_export.py new file mode 100644 index 0000000..2910ee1 --- /dev/null +++ b/src/tests/test_index_import_export.py @@ -0,0 +1,55 @@ +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest +import sys +from cryptography.fernet import Fernet + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.encryption import EncryptionManager +from password_manager.vault import Vault +from utils.key_derivation import derive_index_key, EncryptionMode + +SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" +PASSWORD = "passw0rd" + + +def setup_vault(tmp: Path, mode: EncryptionMode) -> Vault: + key = derive_index_key(SEED, PASSWORD, mode) + enc_mgr = EncryptionManager(key, tmp) + enc_mgr.encrypt_parent_seed(SEED) + return Vault(enc_mgr, tmp) + + +@pytest.mark.parametrize( + "mode", + [ + EncryptionMode.SEED_ONLY, + EncryptionMode.SEED_PLUS_PW, + EncryptionMode.PW_ONLY, + ], +) +def test_index_export_import_round_trip(mode): + with TemporaryDirectory() as td: + tmp = Path(td) + vault = setup_vault(tmp, mode) + + original = {"passwords": {"0": {"website": "example"}}} + vault.save_index(original) + + encrypted = vault.get_encrypted_index() + assert isinstance(encrypted, bytes) + + vault.save_index({"passwords": {"0": {"website": "changed"}}}) + vault.decrypt_and_save_index_from_nostr(encrypted) + + loaded = vault.load_index() + assert loaded["passwords"] == original["passwords"] + + +def test_get_encrypted_index_missing_file(tmp_path): + key = Fernet.generate_key() + enc_mgr = EncryptionManager(key, tmp_path) + vault = Vault(enc_mgr, tmp_path) + assert vault.get_encrypted_index() is None From 8812de1618f1b8ae2149978f2326a624b7dc43aa Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 1 Jul 2025 22:21:53 -0400 Subject: [PATCH 08/13] Add optional parent_seed to portable backup --- src/password_manager/manager.py | 8 +++- src/password_manager/portable_backup.py | 14 +++++- src/tests/test_index_import_export.py | 11 ++++- src/tests/test_portable_backup.py | 63 ++++++++++++++++++++----- 4 files changed, 79 insertions(+), 17 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index e2e6b52..def769a 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1154,6 +1154,7 @@ class PasswordManager: self.backup_manager, mode, dest, + parent_seed=self.parent_seed, ) print(colored(f"Database exported to '{path}'.", "green")) return path @@ -1165,7 +1166,12 @@ class PasswordManager: def handle_import_database(self, src: Path) -> None: """Import a portable database file, replacing the current index.""" try: - import_backup(self.vault, self.backup_manager, src) + import_backup( + self.vault, + self.backup_manager, + src, + parent_seed=self.parent_seed, + ) print(colored("Database imported successfully.", "green")) except Exception as e: logging.error(f"Failed to import database: {e}", exc_info=True) diff --git a/src/password_manager/portable_backup.py b/src/password_manager/portable_backup.py index b027545..5f9df7e 100644 --- a/src/password_manager/portable_backup.py +++ b/src/password_manager/portable_backup.py @@ -55,6 +55,7 @@ def export_backup( dest_path: Path | None = None, *, publish: bool = False, + parent_seed: str | None = None, ) -> Path: """Export the current vault state to a portable encrypted file.""" @@ -65,7 +66,11 @@ def export_backup( dest_path = dest_dir / EXPORT_NAME_TEMPLATE.format(ts=ts) index_data = vault.load_index() - seed = vault.encryption_manager.decrypt_parent_seed() + seed = ( + parent_seed + if parent_seed is not None + else vault.encryption_manager.decrypt_parent_seed() + ) password = None if mode in (PortableMode.SEED_PLUS_PW, PortableMode.PW_ONLY): password = prompt_existing_password("Enter your master password: ") @@ -109,6 +114,7 @@ def import_backup( vault: Vault, backup_manager: BackupManager, path: Path, + parent_seed: str | None = None, ) -> None: """Import a portable backup file and replace the current index.""" @@ -123,7 +129,11 @@ def import_backup( mode = PortableMode(wrapper.get("encryption_mode", PortableMode.SEED_ONLY.value)) payload = base64.b64decode(wrapper["payload"]) - seed = vault.encryption_manager.decrypt_parent_seed() + seed = ( + parent_seed + if parent_seed is not None + else vault.encryption_manager.decrypt_parent_seed() + ) password = None if mode in (PortableMode.SEED_PLUS_PW, PortableMode.PW_ONLY): password = prompt_existing_password("Enter your master password: ") diff --git a/src/tests/test_index_import_export.py b/src/tests/test_index_import_export.py index 2910ee1..46f170d 100644 --- a/src/tests/test_index_import_export.py +++ b/src/tests/test_index_import_export.py @@ -9,16 +9,23 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.encryption import EncryptionManager from password_manager.vault import Vault -from utils.key_derivation import derive_index_key, EncryptionMode +from utils.key_derivation import ( + derive_index_key, + derive_key_from_password, + EncryptionMode, +) SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" PASSWORD = "passw0rd" def setup_vault(tmp: Path, mode: EncryptionMode) -> Vault: + seed_key = derive_key_from_password(PASSWORD) + seed_mgr = EncryptionManager(seed_key, tmp) + seed_mgr.encrypt_parent_seed(SEED) + key = derive_index_key(SEED, PASSWORD, mode) enc_mgr = EncryptionManager(key, tmp) - enc_mgr.encrypt_parent_seed(SEED) return Vault(enc_mgr, tmp) diff --git a/src/tests/test_portable_backup.py b/src/tests/test_portable_backup.py index ba78dd1..16d1e3c 100644 --- a/src/tests/test_portable_backup.py +++ b/src/tests/test_portable_backup.py @@ -16,7 +16,11 @@ from password_manager.portable_backup import ( export_backup, import_backup, ) -from utils.key_derivation import derive_index_key, EncryptionMode +from utils.key_derivation import ( + derive_index_key, + derive_key_from_password, + EncryptionMode, +) SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" @@ -24,9 +28,12 @@ PASSWORD = "passw0rd" def setup_vault(tmp: Path, mode: EncryptionMode = EncryptionMode.SEED_ONLY): + seed_key = derive_key_from_password(PASSWORD) + seed_mgr = EncryptionManager(seed_key, tmp) + seed_mgr.encrypt_parent_seed(SEED) + index_key = derive_index_key(SEED, PASSWORD, mode) enc_mgr = EncryptionManager(index_key, tmp) - enc_mgr.encrypt_parent_seed(SEED) vault = Vault(enc_mgr, tmp) backup = BackupManager(tmp) return vault, backup @@ -49,11 +56,11 @@ def test_round_trip_across_modes(monkeypatch): lambda *_a, **_k: PASSWORD, ) - path = export_backup(vault, backup, pmode) + path = export_backup(vault, backup, pmode, parent_seed=SEED) assert path.exists() vault.save_index({"pw": 0}) - import_backup(vault, backup, path) + import_backup(vault, backup, path, parent_seed=SEED) assert vault.load_index()["pw"] == data["pw"] @@ -70,7 +77,7 @@ def test_corruption_detection(monkeypatch): "password_manager.portable_backup.prompt_existing_password", lambda *_a, **_k: PASSWORD, ) - path = export_backup(vault, backup, PortableMode.SEED_ONLY) + path = export_backup(vault, backup, PortableMode.SEED_ONLY, parent_seed=SEED) content = json.loads(path.read_text()) payload = base64.b64decode(content["payload"]) @@ -79,7 +86,7 @@ def test_corruption_detection(monkeypatch): path.write_text(json.dumps(content)) with pytest.raises(InvalidToken): - import_backup(vault, backup, path) + import_backup(vault, backup, path, parent_seed=SEED) def test_incorrect_credentials(monkeypatch): @@ -92,14 +99,19 @@ def test_incorrect_credentials(monkeypatch): "password_manager.portable_backup.prompt_existing_password", lambda *_a, **_k: PASSWORD, ) - path = export_backup(vault, backup, PortableMode.SEED_PLUS_PW) + path = export_backup( + vault, + backup, + PortableMode.SEED_PLUS_PW, + parent_seed=SEED, + ) monkeypatch.setattr( "password_manager.portable_backup.prompt_existing_password", lambda *_a, **_k: "wrong", ) with pytest.raises(Exception): - import_backup(vault, backup, path) + import_backup(vault, backup, path, parent_seed=SEED) def test_import_over_existing(monkeypatch): @@ -112,10 +124,10 @@ def test_import_over_existing(monkeypatch): "password_manager.portable_backup.prompt_existing_password", lambda *_a, **_k: PASSWORD, ) - path = export_backup(vault, backup, PortableMode.SEED_ONLY) + path = export_backup(vault, backup, PortableMode.SEED_ONLY, parent_seed=SEED) vault.save_index({"v": 2}) - import_backup(vault, backup, path) + import_backup(vault, backup, path, parent_seed=SEED) loaded = vault.load_index() assert loaded["v"] == 1 @@ -131,7 +143,12 @@ def test_checksum_mismatch_detection(monkeypatch): lambda *_a, **_k: PASSWORD, ) - path = export_backup(vault, backup, PortableMode.SEED_ONLY) + path = export_backup( + vault, + backup, + PortableMode.SEED_ONLY, + parent_seed=SEED, + ) wrapper = json.loads(path.read_text()) payload = base64.b64decode(wrapper["payload"]) @@ -145,4 +162,26 @@ def test_checksum_mismatch_detection(monkeypatch): path.write_text(json.dumps(wrapper)) with pytest.raises(ValueError): - import_backup(vault, backup, path) + import_backup(vault, backup, path, parent_seed=SEED) + + +@pytest.mark.parametrize( + "pmode", + [PortableMode.SEED_ONLY, PortableMode.SEED_PLUS_PW], +) +def test_export_import_seed_encrypted_with_different_key(monkeypatch, pmode): + """Ensure backup round trip works when seed is encrypted with another key.""" + with TemporaryDirectory() as td: + tmp = Path(td) + vault, backup = setup_vault(tmp) + vault.save_index({"v": 123}) + + monkeypatch.setattr( + "password_manager.portable_backup.prompt_existing_password", + lambda *_a, **_k: PASSWORD, + ) + + path = export_backup(vault, backup, pmode, parent_seed=SEED) + vault.save_index({"v": 0}) + import_backup(vault, backup, path, parent_seed=SEED) + assert vault.load_index()["v"] == 123 From 8b5b4856ffb248336aee8be2f77b8b68745bc9fa Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 1 Jul 2025 22:30:06 -0400 Subject: [PATCH 09/13] Handle missing checksum file --- README.md | 6 ++++++ src/password_manager/manager.py | 14 +++++++++++++- src/tests/test_manager_checksum_backup.py | 15 +++++++++++++++ src/utils/checksum.py | 22 ++++++++-------------- 4 files changed, 42 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index a48f348..05754c6 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,12 @@ pre-commit install -t pre-push After running this command, every `git push` will execute `scripts/update_checksum.py`, updating the checksum file automatically. +If the checksum file is missing, generate it manually: + +```bash +python scripts/update_checksum.py +``` + To run mutation tests locally, generate coverage data first and then execute `mutmut`: ```bash diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index def769a..f28edad 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1059,7 +1059,19 @@ class PasswordManager: """ try: current_checksum = calculate_checksum(__file__) - if verify_checksum(current_checksum, SCRIPT_CHECKSUM_FILE): + try: + verified = verify_checksum(current_checksum, SCRIPT_CHECKSUM_FILE) + except FileNotFoundError: + print( + colored( + "Checksum file missing. Run scripts/update_checksum.py to generate it.", + "yellow", + ) + ) + logging.warning("Checksum file missing during verification.") + return + + if verified: print(colored("Checksum verification passed.", "green")) logging.info("Checksum verification passed.") else: diff --git a/src/tests/test_manager_checksum_backup.py b/src/tests/test_manager_checksum_backup.py index c367e34..4f625ca 100644 --- a/src/tests/test_manager_checksum_backup.py +++ b/src/tests/test_manager_checksum_backup.py @@ -44,6 +44,21 @@ def test_handle_verify_checksum_failure(monkeypatch, tmp_path, capsys): assert "Checksum verification failed" in out +def test_handle_verify_checksum_missing(monkeypatch, tmp_path, capsys): + pm = _make_pm() + chk_file = tmp_path / "chk.txt" + monkeypatch.setattr("password_manager.manager.SCRIPT_CHECKSUM_FILE", chk_file) + monkeypatch.setattr("password_manager.manager.calculate_checksum", lambda _: "abc") + + def raise_missing(*_args, **_kwargs): + raise FileNotFoundError + + monkeypatch.setattr("password_manager.manager.verify_checksum", raise_missing) + pm.handle_verify_checksum() + out = capsys.readouterr().out.lower() + assert "update_checksum.py" in out + + def test_backup_and_restore_database(monkeypatch, capsys): pm = _make_pm() calls = {"create": 0, "restore": 0} diff --git a/src/utils/checksum.py b/src/utils/checksum.py index 983e060..3af6a9c 100644 --- a/src/utils/checksum.py +++ b/src/utils/checksum.py @@ -89,26 +89,20 @@ def verify_checksum(current_checksum: str, checksum_file_path: str) -> bool: try: with open(checksum_file_path, "r") as f: stored_checksum = f.read().strip() - if current_checksum == stored_checksum: - logging.debug(f"Checksum verification passed for '{checksum_file_path}'.") - return True - else: - logging.warning(f"Checksum mismatch for '{checksum_file_path}'.") - return False except FileNotFoundError: logging.error(f"Checksum file '{checksum_file_path}' not found.") - print(colored(f"Error: Checksum file '{checksum_file_path}' not found.", "red")) - return False + raise except Exception as e: logging.error( f"Error reading checksum file '{checksum_file_path}': {e}", exc_info=True ) - print( - colored( - f"Error: Failed to read checksum file '{checksum_file_path}': {e}", - "red", - ) - ) + raise + + if current_checksum == stored_checksum: + logging.debug(f"Checksum verification passed for '{checksum_file_path}'.") + return True + else: + logging.warning(f"Checksum mismatch for '{checksum_file_path}'.") return False From cbc5d237d138a8a9e25551f5fa8a58d0f1c34bb2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 1 Jul 2025 22:45:55 -0400 Subject: [PATCH 10/13] test: derive keys for vault setup --- src/tests/helpers.py | 32 +++++++++++++++++++ src/tests/test_backup_restore.py | 8 ++--- src/tests/test_concurrency_stress.py | 31 +++++++++++------- src/tests/test_config_manager.py | 27 ++++------------ src/tests/test_entries_empty.py | 7 ++-- src/tests/test_entry_add.py | 7 ++-- .../test_entry_management_checksum_path.py | 13 +++----- src/tests/test_index_import_export.py | 6 ++-- src/tests/test_manager_workflow.py | 7 ++-- src/tests/test_migrations.py | 8 ++--- src/tests/test_nostr_backup.py | 7 ++-- src/tests/test_password_change.py | 6 ++-- src/tests/test_profile_management.py | 7 ++-- src/tests/test_seed_import.py | 5 +-- src/tests/test_settings_menu.py | 6 ++-- 15 files changed, 85 insertions(+), 92 deletions(-) create mode 100644 src/tests/helpers.py diff --git a/src/tests/helpers.py b/src/tests/helpers.py new file mode 100644 index 0000000..22c55cf --- /dev/null +++ b/src/tests/helpers.py @@ -0,0 +1,32 @@ +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.vault import Vault +from password_manager.encryption import EncryptionManager +from utils.key_derivation import ( + derive_index_key, + derive_key_from_password, + EncryptionMode, +) + +TEST_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" +TEST_PASSWORD = "pw" + + +def create_vault( + dir_path: Path, + seed: str = TEST_SEED, + password: str = TEST_PASSWORD, + mode: EncryptionMode = EncryptionMode.SEED_ONLY, +) -> tuple[Vault, EncryptionManager]: + """Create a Vault initialized for tests.""" + seed_key = derive_key_from_password(password) + seed_mgr = EncryptionManager(seed_key, dir_path) + seed_mgr.encrypt_parent_seed(seed) + + index_key = derive_index_key(seed, password, mode) + enc_mgr = EncryptionManager(index_key, dir_path) + vault = Vault(enc_mgr, dir_path) + return vault, enc_mgr diff --git a/src/tests/test_backup_restore.py b/src/tests/test_backup_restore.py index 3abe76a..08e2f3e 100644 --- a/src/tests/test_backup_restore.py +++ b/src/tests/test_backup_restore.py @@ -4,21 +4,17 @@ import time from pathlib import Path from tempfile import TemporaryDirectory -from cryptography.fernet import Fernet +from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault from password_manager.backup import BackupManager def test_backup_restore_workflow(monkeypatch): with TemporaryDirectory() as tmpdir: fp_dir = Path(tmpdir) - key = Fernet.generate_key() - enc_mgr = EncryptionManager(key, fp_dir) - vault = Vault(enc_mgr, fp_dir) + vault, enc_mgr = create_vault(fp_dir, TEST_SEED, TEST_PASSWORD) backup_mgr = BackupManager(fp_dir) index_file = fp_dir / "seedpass_passwords_db.json.enc" diff --git a/src/tests/test_concurrency_stress.py b/src/tests/test_concurrency_stress.py index 5d07874..0e16893 100644 --- a/src/tests/test_concurrency_stress.py +++ b/src/tests/test_concurrency_stress.py @@ -1,19 +1,24 @@ import sys from pathlib import Path from multiprocessing import Process, Queue -from cryptography.fernet import Fernet import pytest +from helpers import TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) from password_manager.encryption import EncryptionManager from password_manager.vault import Vault from password_manager.backup import BackupManager +from utils.key_derivation import ( + derive_index_key, + derive_key_from_password, + EncryptionMode, +) -def _writer(key: bytes, dir_path: Path, loops: int, out: Queue) -> None: +def _writer(index_key: bytes, dir_path: Path, loops: int, out: Queue) -> None: try: - enc = EncryptionManager(key, dir_path) + enc = EncryptionManager(index_key, dir_path) vault = Vault(enc, dir_path) for _ in range(loops): data = vault.load_index() @@ -23,9 +28,9 @@ def _writer(key: bytes, dir_path: Path, loops: int, out: Queue) -> None: out.put(repr(e)) -def _reader(key: bytes, dir_path: Path, loops: int, out: Queue) -> None: +def _reader(index_key: bytes, dir_path: Path, loops: int, out: Queue) -> None: try: - enc = EncryptionManager(key, dir_path) + enc = EncryptionManager(index_key, dir_path) vault = Vault(enc, dir_path) for _ in range(loops): vault.load_index() @@ -45,16 +50,18 @@ def _backup(dir_path: Path, loops: int, out: Queue) -> None: @pytest.mark.parametrize("loops", [5, pytest.param(20, marks=pytest.mark.stress)]) @pytest.mark.parametrize("_", range(3)) def test_concurrency_stress(tmp_path: Path, loops: int, _): - key = Fernet.generate_key() - enc = EncryptionManager(key, tmp_path) + index_key = derive_index_key(TEST_SEED, TEST_PASSWORD, EncryptionMode.SEED_ONLY) + seed_key = derive_key_from_password(TEST_PASSWORD) + EncryptionManager(seed_key, tmp_path).encrypt_parent_seed(TEST_SEED) + enc = EncryptionManager(index_key, tmp_path) Vault(enc, tmp_path).save_index({"counter": 0}) q: Queue = Queue() procs = [ - Process(target=_writer, args=(key, tmp_path, loops, q)), - Process(target=_writer, args=(key, tmp_path, loops, q)), - Process(target=_reader, args=(key, tmp_path, loops, q)), - Process(target=_reader, args=(key, tmp_path, loops, q)), + Process(target=_writer, args=(index_key, tmp_path, loops, q)), + Process(target=_writer, args=(index_key, tmp_path, loops, q)), + Process(target=_reader, args=(index_key, tmp_path, loops, q)), + Process(target=_reader, args=(index_key, tmp_path, loops, q)), Process(target=_backup, args=(tmp_path, loops, q)), ] @@ -69,5 +76,5 @@ def test_concurrency_stress(tmp_path: Path, loops: int, _): assert not errors - vault = Vault(EncryptionManager(key, tmp_path), tmp_path) + vault = Vault(EncryptionManager(index_key, tmp_path), tmp_path) assert isinstance(vault.load_index(), dict) diff --git a/src/tests/test_config_manager.py b/src/tests/test_config_manager.py index c64a7e7..7433dba 100644 --- a/src/tests/test_config_manager.py +++ b/src/tests/test_config_manager.py @@ -1,13 +1,12 @@ import bcrypt from pathlib import Path from tempfile import TemporaryDirectory -from cryptography.fernet import Fernet import pytest +from helpers import create_vault, TEST_SEED, TEST_PASSWORD import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager from password_manager.config_manager import ConfigManager from password_manager.vault import Vault from nostr.client import DEFAULT_RELAYS @@ -15,9 +14,7 @@ from nostr.client import DEFAULT_RELAYS def test_config_defaults_and_round_trip(): with TemporaryDirectory() as tmpdir: - key = Fernet.generate_key() - enc_mgr = EncryptionManager(key, Path(tmpdir)) - vault = Vault(enc_mgr, Path(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) @@ -35,9 +32,7 @@ def test_config_defaults_and_round_trip(): def test_pin_verification_and_change(): with TemporaryDirectory() as tmpdir: - key = Fernet.generate_key() - enc_mgr = EncryptionManager(key, Path(tmpdir)) - vault = Vault(enc_mgr, Path(tmpdir)) + vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) cfg_mgr = ConfigManager(vault, Path(tmpdir)) cfg_mgr.set_pin("1234") @@ -52,9 +47,7 @@ import json def test_config_file_encrypted_after_save(): with TemporaryDirectory() as tmpdir: - key = Fernet.generate_key() - enc_mgr = EncryptionManager(key, Path(tmpdir)) - vault = Vault(enc_mgr, Path(tmpdir)) + vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) cfg_mgr = ConfigManager(vault, Path(tmpdir)) data = {"relays": ["wss://r"], "pin_hash": ""} @@ -72,9 +65,7 @@ def test_config_file_encrypted_after_save(): def test_set_relays_persists_changes(): with TemporaryDirectory() as tmpdir: - key = Fernet.generate_key() - enc_mgr = EncryptionManager(key, Path(tmpdir)) - vault = Vault(enc_mgr, Path(tmpdir)) + vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) cfg_mgr = ConfigManager(vault, Path(tmpdir)) cfg_mgr.set_relays(["wss://custom"], require_pin=False) cfg = cfg_mgr.load_config(require_pin=False) @@ -83,18 +74,14 @@ def test_set_relays_persists_changes(): def test_set_relays_requires_at_least_one(): with TemporaryDirectory() as tmpdir: - key = Fernet.generate_key() - enc_mgr = EncryptionManager(key, Path(tmpdir)) - vault = Vault(enc_mgr, Path(tmpdir)) + vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) cfg_mgr = ConfigManager(vault, Path(tmpdir)) with pytest.raises(ValueError): cfg_mgr.set_relays([], require_pin=False) def test_password_hash_migrates_from_file(tmp_path): - key = Fernet.generate_key() - enc_mgr = EncryptionManager(key, tmp_path) - vault = Vault(enc_mgr, tmp_path) + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) cfg_mgr = ConfigManager(vault, tmp_path) # save legacy config without password_hash diff --git a/src/tests/test_entries_empty.py b/src/tests/test_entries_empty.py index 0e3328d..4c466b5 100644 --- a/src/tests/test_entries_empty.py +++ b/src/tests/test_entries_empty.py @@ -1,20 +1,17 @@ import sys from pathlib import Path from tempfile import TemporaryDirectory -from cryptography.fernet import Fernet +from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager from password_manager.entry_management import EntryManager from password_manager.vault import Vault def test_list_entries_empty(): with TemporaryDirectory() as tmpdir: - key = Fernet.generate_key() - enc_mgr = EncryptionManager(key, Path(tmpdir)) - vault = Vault(enc_mgr, Path(tmpdir)) + vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) entry_mgr = EntryManager(vault, Path(tmpdir)) entries = entry_mgr.list_entries() diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index 1f9883f..30ebab4 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -1,20 +1,17 @@ import sys from pathlib import Path from tempfile import TemporaryDirectory -from cryptography.fernet import Fernet +from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager from password_manager.entry_management import EntryManager from password_manager.vault import Vault def test_add_and_retrieve_entry(): with TemporaryDirectory() as tmpdir: - key = Fernet.generate_key() - enc_mgr = EncryptionManager(key, Path(tmpdir)) - vault = Vault(enc_mgr, Path(tmpdir)) + vault, enc_mgr = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) entry_mgr = EntryManager(vault, Path(tmpdir)) index = entry_mgr.add_entry("example.com", 12, "user") diff --git a/src/tests/test_entry_management_checksum_path.py b/src/tests/test_entry_management_checksum_path.py index 0a6b914..b1f6e13 100644 --- a/src/tests/test_entry_management_checksum_path.py +++ b/src/tests/test_entry_management_checksum_path.py @@ -1,21 +1,18 @@ import sys from pathlib import Path from tempfile import TemporaryDirectory -from cryptography.fernet import Fernet +from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault from password_manager.entry_management import EntryManager +from password_manager.vault import Vault def test_update_checksum_writes_to_expected_path(): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) - key = Fernet.generate_key() - enc_mgr = EncryptionManager(key, tmp_path) - vault = Vault(enc_mgr, tmp_path) + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) entry_mgr = EntryManager(vault, tmp_path) # create an empty index file @@ -29,9 +26,7 @@ def test_update_checksum_writes_to_expected_path(): def test_backup_index_file_creates_backup_in_directory(): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) - key = Fernet.generate_key() - enc_mgr = EncryptionManager(key, tmp_path) - vault = Vault(enc_mgr, tmp_path) + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) entry_mgr = EntryManager(vault, tmp_path) vault.save_index({"passwords": {}}) diff --git a/src/tests/test_index_import_export.py b/src/tests/test_index_import_export.py index 46f170d..b9d7fbb 100644 --- a/src/tests/test_index_import_export.py +++ b/src/tests/test_index_import_export.py @@ -3,7 +3,7 @@ from tempfile import TemporaryDirectory import pytest import sys -from cryptography.fernet import Fernet +from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -56,7 +56,5 @@ def test_index_export_import_round_trip(mode): def test_get_encrypted_index_missing_file(tmp_path): - key = Fernet.generate_key() - enc_mgr = EncryptionManager(key, tmp_path) - vault = Vault(enc_mgr, tmp_path) + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) assert vault.get_encrypted_index() is None diff --git a/src/tests/test_manager_workflow.py b/src/tests/test_manager_workflow.py index 8694591..d651336 100644 --- a/src/tests/test_manager_workflow.py +++ b/src/tests/test_manager_workflow.py @@ -1,11 +1,10 @@ import sys from pathlib import Path from tempfile import TemporaryDirectory -from cryptography.fernet import Fernet +from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager from password_manager.entry_management import EntryManager from password_manager.vault import Vault from password_manager.backup import BackupManager @@ -29,9 +28,7 @@ class FakeNostrClient: def test_manager_workflow(monkeypatch): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) - key = Fernet.generate_key() - enc_mgr = EncryptionManager(key, tmp_path) - vault = Vault(enc_mgr, tmp_path) + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) entry_mgr = EntryManager(vault, tmp_path) backup_mgr = BackupManager(tmp_path) diff --git a/src/tests/test_migrations.py b/src/tests/test_migrations.py index fa14ef2..c212c2a 100644 --- a/src/tests/test_migrations.py +++ b/src/tests/test_migrations.py @@ -1,19 +1,15 @@ import sys from pathlib import Path import pytest -from cryptography.fernet import Fernet +from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault from password_manager.migrations import LATEST_VERSION def setup(tmp_path: Path): - key = Fernet.generate_key() - enc_mgr = EncryptionManager(key, tmp_path) - vault = Vault(enc_mgr, tmp_path) + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) return enc_mgr, vault diff --git a/src/tests/test_nostr_backup.py b/src/tests/test_nostr_backup.py index 81a2b9d..a7c966c 100644 --- a/src/tests/test_nostr_backup.py +++ b/src/tests/test_nostr_backup.py @@ -2,11 +2,10 @@ import sys from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import patch -from cryptography.fernet import Fernet +from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager from password_manager.entry_management import EntryManager from password_manager.vault import Vault from nostr.client import NostrClient @@ -15,9 +14,7 @@ from nostr.client import NostrClient def test_backup_and_publish_to_nostr(): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) - key = Fernet.generate_key() - enc_mgr = EncryptionManager(key, tmp_path) - vault = Vault(enc_mgr, tmp_path) + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) entry_mgr = EntryManager(vault, tmp_path) # create an index by adding an entry diff --git a/src/tests/test_password_change.py b/src/tests/test_password_change.py index d986b37..03d66e5 100644 --- a/src/tests/test_password_change.py +++ b/src/tests/test_password_change.py @@ -4,11 +4,10 @@ from tempfile import TemporaryDirectory from types import SimpleNamespace from unittest.mock import patch -from cryptography.fernet import Fernet +from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager from password_manager.entry_management import EntryManager from password_manager.config_manager import ConfigManager from password_manager.vault import Vault @@ -18,8 +17,7 @@ from password_manager.manager import PasswordManager def test_change_password_triggers_nostr_backup(monkeypatch): with TemporaryDirectory() as tmpdir: fp = Path(tmpdir) - enc_mgr = EncryptionManager(Fernet.generate_key(), fp) - vault = Vault(enc_mgr, fp) + vault, enc_mgr = create_vault(fp, TEST_SEED, TEST_PASSWORD) entry_mgr = EntryManager(vault, fp) cfg_mgr = ConfigManager(vault, fp) diff --git a/src/tests/test_profile_management.py b/src/tests/test_profile_management.py index da78fcf..a413c88 100644 --- a/src/tests/test_profile_management.py +++ b/src/tests/test_profile_management.py @@ -4,7 +4,7 @@ from pathlib import Path from tempfile import TemporaryDirectory from types import SimpleNamespace -from cryptography.fernet import Fernet +from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -12,7 +12,6 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from utils.fingerprint_manager import FingerprintManager import constants import password_manager.manager as manager_module -from password_manager.encryption import EncryptionManager from password_manager.vault import Vault from password_manager.entry_management import EntryManager @@ -49,9 +48,7 @@ def test_add_and_delete_entry(monkeypatch): assert fingerprint_dir.exists() assert pm.fingerprint_manager.current_fingerprint == fingerprint - key = Fernet.generate_key() - enc_mgr = EncryptionManager(key, fingerprint_dir) - vault = Vault(enc_mgr, fingerprint_dir) + vault, enc_mgr = create_vault(fingerprint_dir, TEST_SEED, TEST_PASSWORD) entry_mgr = EntryManager(vault, fingerprint_dir) pm.encryption_manager = enc_mgr diff --git a/src/tests/test_seed_import.py b/src/tests/test_seed_import.py index b88263d..d3b3088 100644 --- a/src/tests/test_seed_import.py +++ b/src/tests/test_seed_import.py @@ -1,7 +1,8 @@ import sys from pathlib import Path from tempfile import TemporaryDirectory -from cryptography.fernet import Fernet +from helpers import TEST_PASSWORD +from utils.key_derivation import derive_key_from_password from mnemonic import Mnemonic sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -12,7 +13,7 @@ from password_manager.manager import PasswordManager def test_seed_encryption_round_trip(): with TemporaryDirectory() as tmpdir: - key = Fernet.generate_key() + key = derive_key_from_password(TEST_PASSWORD) enc_mgr = EncryptionManager(key, Path(tmpdir)) seed = Mnemonic("english").generate(strength=128) diff --git a/src/tests/test_settings_menu.py b/src/tests/test_settings_menu.py index c02f22b..7a0c0ee 100644 --- a/src/tests/test_settings_menu.py +++ b/src/tests/test_settings_menu.py @@ -5,13 +5,12 @@ from tempfile import TemporaryDirectory from types import SimpleNamespace from unittest.mock import patch -from cryptography.fernet import Fernet +from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) import main from nostr.client import DEFAULT_RELAYS -from password_manager.encryption import EncryptionManager from password_manager.config_manager import ConfigManager from password_manager.vault import Vault from utils.fingerprint_manager import FingerprintManager @@ -26,8 +25,7 @@ def setup_pm(tmp_path, monkeypatch): fp_dir = constants.APP_DIR / "fp" fp_dir.mkdir(parents=True) - enc_mgr = EncryptionManager(Fernet.generate_key(), fp_dir) - vault = Vault(enc_mgr, fp_dir) + vault, enc_mgr = create_vault(fp_dir, TEST_SEED, TEST_PASSWORD) cfg_mgr = ConfigManager(vault, fp_dir) fp_mgr = FingerprintManager(constants.APP_DIR) From 67ee020210e7237bad6aad7b290c53a392536252 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 1 Jul 2025 22:51:11 -0400 Subject: [PATCH 11/13] Ignore multiprocessing DeprecationWarning --- pytest.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pytest.ini b/pytest.ini index 2c87d77..1aa25c7 100644 --- a/pytest.ini +++ b/pytest.ini @@ -7,3 +7,5 @@ testpaths = src/tests markers = network: tests that require network connectivity stress: long running stress tests +filterwarnings = + ignore::DeprecationWarning:multiprocessing.popen_fork From ddee3d5ccc1764b2a2dc0006cc19488fd3027c85 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 1 Jul 2025 23:01:38 -0400 Subject: [PATCH 12/13] Fix password hash storage when adding new seed profile --- src/password_manager/manager.py | 13 +++++++++++++ src/password_manager/migrations.py | 4 +++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index f28edad..a5387a2 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -548,6 +548,12 @@ class PasswordManager: seed_mgr = EncryptionManager(seed_key, fingerprint_dir) self.vault = Vault(self.encryption_manager, fingerprint_dir) + # Ensure config manager is set for the new fingerprint + self.config_manager = ConfigManager( + vault=self.vault, + fingerprint_dir=fingerprint_dir, + ) + # Encrypt and save the parent seed seed_mgr.encrypt_parent_seed(parent_seed) logging.info("Parent seed encrypted and saved successfully.") @@ -684,6 +690,13 @@ class PasswordManager: self.vault = Vault(self.encryption_manager, fingerprint_dir) + # Ensure the config manager points to the new fingerprint before + # storing the hashed password + self.config_manager = ConfigManager( + vault=self.vault, + fingerprint_dir=fingerprint_dir, + ) + self.store_hashed_password(password) logging.info("User password hashed and stored successfully.") diff --git a/src/password_manager/migrations.py b/src/password_manager/migrations.py index e1e6843..5984279 100644 --- a/src/password_manager/migrations.py +++ b/src/password_manager/migrations.py @@ -7,7 +7,9 @@ from typing import Callable, Dict MIGRATIONS: Dict[int, Callable[[dict], dict]] = {} -def migration(from_ver: int) -> Callable[[Callable[[dict], dict]], Callable[[dict], dict]]: +def migration( + from_ver: int, +) -> Callable[[Callable[[dict], dict]], Callable[[dict], dict]]: """Register a migration function from *from_ver* to *from_ver* + 1.""" def decorator(func: Callable[[dict], dict]) -> Callable[[dict], dict]: From 4c7b0edabc7f071f064cf74e9f2fe0bf4915e6e1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 1 Jul 2025 23:15:33 -0400 Subject: [PATCH 13/13] Fix profile switching to reload parent seed --- src/password_manager/encryption.py | 14 --------- src/password_manager/manager.py | 46 ++++++++++++++++++++---------- src/tests/test_profiles.py | 2 +- 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/src/password_manager/encryption.py b/src/password_manager/encryption.py index 81d7cd9..f63fcbf 100644 --- a/src/password_manager/encryption.py +++ b/src/password_manager/encryption.py @@ -121,7 +121,6 @@ class EncryptionManager: logger.error( "Invalid encryption key or corrupted data while decrypting parent seed." ) - print(colored("Error: Invalid encryption key or corrupted data.", "red")) raise except Exception as e: logger.error(f"Failed to decrypt parent seed: {e}", exc_info=True) @@ -159,7 +158,6 @@ class EncryptionManager: logger.error( "Invalid encryption key or corrupted data while decrypting data." ) - print(colored("Error: Invalid encryption key or corrupted data.", "red")) raise except Exception as e: logger.error(f"Failed to decrypt data: {e}", exc_info=True) @@ -230,7 +228,6 @@ class EncryptionManager: logger.error( "Invalid encryption key or corrupted data while decrypting file." ) - print(colored("Error: Invalid encryption key or corrupted data.", "red")) raise except Exception as e: logger.error( @@ -306,27 +303,16 @@ class EncryptionManager: logger.error( f"Failed to decode JSON data from '{file_path}': {e}", exc_info=True ) - print( - colored( - f"Error: Failed to decode JSON data from '{file_path}': {e}", "red" - ) - ) raise except InvalidToken: logger.error( "Invalid encryption key or corrupted data while decrypting JSON data." ) - print(colored("Error: Invalid encryption key or corrupted data.", "red")) raise except Exception as e: logger.error( f"Failed to load JSON data from '{file_path}': {e}", exc_info=True ) - print( - colored( - f"Error: Failed to load JSON data from '{file_path}': {e}", "red" - ) - ) raise def update_checksum(self, relative_path: Optional[Path] = None) -> None: diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index a5387a2..e0adcfe 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -256,22 +256,28 @@ class PasswordManager: sys.exit(1) def setup_encryption_manager( - self, fingerprint_dir: Path, password: Optional[str] = None - ) -> None: + self, + fingerprint_dir: Path, + password: Optional[str] = None, + *, + exit_on_fail: bool = True, + ) -> bool: """Set up encryption for the current fingerprint and load the seed.""" try: if password is None: password = prompt_existing_password("Enter your master password: ") - if not self.parent_seed: - seed_key = derive_key_from_password(password) - seed_mgr = EncryptionManager(seed_key, fingerprint_dir) - try: - self.parent_seed = seed_mgr.decrypt_parent_seed() - except Exception: - print(colored("Invalid password. Exiting.", "red")) - raise + seed_key = derive_key_from_password(password) + seed_mgr = EncryptionManager(seed_key, fingerprint_dir) + try: + self.parent_seed = seed_mgr.decrypt_parent_seed() + except Exception: + msg = "Invalid password for selected seed profile." + print(colored(msg, "red")) + if exit_on_fail: + sys.exit(1) + return False key = derive_index_key( self.parent_seed, @@ -289,12 +295,17 @@ class PasswordManager: self.fingerprint_dir = fingerprint_dir if not self.verify_password(password): - print(colored("Invalid password. Exiting.", "red")) - sys.exit(1) + print(colored("Invalid password.", "red")) + if exit_on_fail: + sys.exit(1) + return False + return True except Exception as e: logger.error(f"Failed to set up EncryptionManager: {e}", exc_info=True) print(colored(f"Error: Failed to set up encryption: {e}", "red")) - sys.exit(1) + if exit_on_fail: + sys.exit(1) + return False def load_parent_seed( self, fingerprint_dir: Path, password: Optional[str] = None @@ -354,10 +365,15 @@ class PasswordManager: return False # Return False to indicate failure # Prompt for master password for the selected seed profile - password = prompt_existing_password("Enter your master password: ") + password = prompt_existing_password( + "Enter the master password for the selected seed profile: " + ) # Set up the encryption manager with the new password and seed profile directory - self.setup_encryption_manager(self.fingerprint_dir, password) + if not self.setup_encryption_manager( + self.fingerprint_dir, password, exit_on_fail=False + ): + return False # Initialize BIP85 and other managers self.initialize_bip85() diff --git a/src/tests/test_profiles.py b/src/tests/test_profiles.py index 634f7d8..43bdfc6 100644 --- a/src/tests/test_profiles.py +++ b/src/tests/test_profiles.py @@ -34,7 +34,7 @@ def test_add_and_switch_fingerprint(monkeypatch): monkeypatch.setattr( PasswordManager, "setup_encryption_manager", - lambda self, d, password=None: None, + lambda self, d, password=None, exit_on_fail=True: True, ) monkeypatch.setattr(PasswordManager, "load_parent_seed", lambda self, d: None) monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None)