Files
seedPass/src/seedpass/core/portable_backup.py
2025-08-20 22:15:35 -04:00

166 lines
4.9 KiB
Python

# portable_backup.py
"""Export and import encrypted profile backups."""
from __future__ import annotations
import base64
import json
import logging
import os
import time
import asyncio
from enum import Enum
from pathlib import Path
from .vault import Vault
from .backup import BackupManager
from nostr.client import NostrClient
from utils.key_derivation import (
derive_index_key,
EncryptionMode,
)
from .encryption import EncryptionManager
from utils.checksum import json_checksum, canonical_json_dumps
from .state_manager import StateManager
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
NONE = "none"
def _derive_export_key(seed: str) -> bytes:
"""Derive the Fernet key for the export payload."""
return derive_index_key(seed)
def export_backup(
vault: Vault,
backup_manager: BackupManager,
dest_path: Path | None = None,
*,
publish: bool = False,
parent_seed: str | None = None,
encrypt: bool = True,
) -> Path:
"""Export the current vault state to a portable file.
When ``encrypt`` is ``True`` (the default) the payload is encrypted with a
key derived from the parent seed. When ``encrypt`` is ``False`` the payload
is written in plaintext and the wrapper records an ``encryption_mode`` of
:data:`PortableMode.NONE`.
"""
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()
canonical = canonical_json_dumps(index_data)
if encrypt:
seed = (
parent_seed
if parent_seed is not None
else vault.encryption_manager.decrypt_parent_seed()
)
key = _derive_export_key(seed)
enc_mgr = EncryptionManager(key, vault.fingerprint_dir)
payload_bytes = enc_mgr.encrypt_data(canonical.encode("utf-8"))
mode = PortableMode.SEED_ONLY
cipher = "aes-gcm"
else:
payload_bytes = canonical.encode("utf-8")
mode = PortableMode.NONE
cipher = "none"
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": cipher,
"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)
backup_manager._create_additional_backup(dest_path)
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:
idx = StateManager(vault.fingerprint_dir).state.get("nostr_account_idx", 0)
client = NostrClient(
vault.encryption_manager,
vault.fingerprint_dir.name,
config_manager=backup_manager.config_manager,
account_index=idx,
)
asyncio.run(client.publish_snapshot(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, context=str(path))
wrapper = json.loads(raw.decode("utf-8"))
if wrapper.get("format_version") != FORMAT_VERSION:
raise ValueError("Unsupported backup format")
mode = wrapper.get("encryption_mode")
payload = base64.b64decode(wrapper["payload"])
if mode == PortableMode.SEED_ONLY.value:
seed = (
parent_seed
if parent_seed is not None
else vault.encryption_manager.decrypt_parent_seed()
)
key = _derive_export_key(seed)
enc_mgr = EncryptionManager(key, vault.fingerprint_dir)
enc_mgr._legacy_migrate_flag = False
index_bytes = enc_mgr.decrypt_data(payload, context="backup payload")
elif mode == PortableMode.NONE.value:
index_bytes = payload
else:
raise ValueError("Unsupported encryption mode")
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)