diff --git a/README.md b/README.md index 49e3ef2..78c8b6a 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,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: @@ -233,6 +250,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/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. 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 { 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 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/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/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/manager.py b/src/password_manager/manager.py index 1f6f5dc..e0adcfe 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, @@ -251,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, @@ -284,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 @@ -349,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() @@ -543,6 +564,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.") @@ -679,6 +706,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.") @@ -1054,7 +1088,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: @@ -1137,6 +1183,41 @@ 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, + parent_seed=self.parent_seed, + ) + 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, + 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) + 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/password_manager/migrations.py b/src/password_manager/migrations.py new file mode 100644 index 0000000..5984279 --- /dev/null +++ b/src/password_manager/migrations.py @@ -0,0 +1,45 @@ +"""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/portable_backup.py b/src/password_manager/portable_backup.py new file mode 100644 index 0000000..5f9df7e --- /dev/null +++ b/src/password_manager/portable_backup.py @@ -0,0 +1,151 @@ +# portable_backup.py +"""Export and import encrypted profile backups.""" + +from __future__ import annotations + +import base64 +import json +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 +from utils.checksum import json_checksum, canonical_json_dumps + +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, + parent_seed: str | None = None, +) -> 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 = ( + 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: ") + + key = _derive_export_key(seed, mode, password) + enc_mgr = EncryptionManager(key, vault.fingerprint_dir) + + 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, + "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, + parent_seed: str | None = None, +) -> 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"]) + + 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: ") + + 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")) + + 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/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/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 56d9329..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" @@ -45,11 +41,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_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_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 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 new file mode 100644 index 0000000..b9d7fbb --- /dev/null +++ b/src/tests/test_index_import_export.py @@ -0,0 +1,60 @@ +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest +import sys +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 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) + 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): + 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_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/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 new file mode 100644 index 0000000..c212c2a --- /dev/null +++ b/src/tests/test_migrations.py @@ -0,0 +1,30 @@ +import sys +from pathlib import Path +import pytest +from helpers import create_vault, TEST_SEED, TEST_PASSWORD + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.migrations import LATEST_VERSION + + +def setup(tmp_path: Path): + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + 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_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_portable_backup.py b/src/tests/test_portable_backup.py new file mode 100644 index 0000000..16d1e3c --- /dev/null +++ b/src/tests/test_portable_backup.py @@ -0,0 +1,187 @@ +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, + 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 = 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) + 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, parent_seed=SEED) + assert path.exists() + + vault.save_index({"pw": 0}) + import_backup(vault, backup, path, parent_seed=SEED) + assert vault.load_index()["pw"] == data["pw"] + + +from cryptography.fernet import InvalidToken + + +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, parent_seed=SEED) + + 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(InvalidToken): + import_backup(vault, backup, path, parent_seed=SEED) + + +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, + 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, parent_seed=SEED) + + +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, parent_seed=SEED) + + vault.save_index({"v": 2}) + import_backup(vault, backup, path, parent_seed=SEED) + 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, + parent_seed=SEED, + ) + + 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, 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 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_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) 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) 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..3af6a9c 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. @@ -77,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