Files
seedPass/src/seedpass/core/portable_backup.py
2025-07-17 19:21:10 -04:00

141 lines
4.0 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
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
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,
) -> 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()
)
key = _derive_export_key(seed)
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": PortableMode.SEED_ONLY.value,
"cipher": "aes-gcm",
"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:
client = NostrClient(
vault.encryption_manager,
vault.fingerprint_dir.name,
config_manager=backup_manager.config_manager,
)
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)
wrapper = json.loads(raw.decode("utf-8"))
if wrapper.get("format_version") != FORMAT_VERSION:
raise ValueError("Unsupported backup format")
if wrapper.get("encryption_mode") != PortableMode.SEED_ONLY.value:
raise ValueError("Unsupported encryption mode")
payload = base64.b64decode(wrapper["payload"])
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)
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)