Merge pull request #117 from PR0M3TH3AN/codex/implement-portable-backup-with-export/import-functionality

Add portable backup support with tests
This commit is contained in:
thePR0M3TH3AN
2025-07-01 20:26:05 -04:00
committed by GitHub
2 changed files with 256 additions and 0 deletions

View File

@@ -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)

View File

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