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] 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}