Merge pull request #642 from PR0M3TH3AN/codex/implement-export-and-import-profile-commands

Add vault profile export/import feature
This commit is contained in:
thePR0M3TH3AN
2025-07-18 16:12:34 -04:00
committed by GitHub
6 changed files with 85 additions and 35 deletions

View File

@@ -207,10 +207,10 @@ create a backup:
seedpass seedpass
# Export your index # Export your index
seedpass export --file "~/seedpass_backup.json" seedpass vault export --file "~/seedpass_backup.json"
# Later you can restore it # Later you can restore it
seedpass import --file "~/seedpass_backup.json" seedpass vault import --file "~/seedpass_backup.json"
# Import also performs a Nostr sync to pull any changes # Import also performs a Nostr sync to pull any changes
# Quickly find or retrieve entries # Quickly find or retrieve entries

View File

@@ -14,8 +14,6 @@ from seedpass.core.api import (
ConfigService, ConfigService,
UtilityService, UtilityService,
NostrService, NostrService,
VaultExportRequest,
VaultImportRequest,
ChangePasswordRequest, ChangePasswordRequest,
UnlockRequest, UnlockRequest,
BackupParentSeedRequest, BackupParentSeedRequest,
@@ -402,9 +400,10 @@ def entry_export_totp(
def vault_export( def vault_export(
ctx: typer.Context, file: str = typer.Option(..., help="Output file") ctx: typer.Context, file: str = typer.Option(..., help="Output file")
) -> None: ) -> None:
"""Export the vault.""" """Export the vault profile to an encrypted file."""
vault_service, _profile, _sync = _get_services(ctx) vault_service, _profile, _sync = _get_services(ctx)
vault_service.export_vault(VaultExportRequest(path=Path(file))) data = vault_service.export_profile()
Path(file).write_bytes(data)
typer.echo(str(file)) typer.echo(str(file))
@@ -412,9 +411,10 @@ def vault_export(
def vault_import( def vault_import(
ctx: typer.Context, file: str = typer.Option(..., help="Input file") ctx: typer.Context, file: str = typer.Option(..., help="Input file")
) -> None: ) -> None:
"""Import a vault from an encrypted JSON file.""" """Import a vault profile from an encrypted file."""
vault_service, _profile, _sync = _get_services(ctx) vault_service, _profile, _sync = _get_services(ctx)
vault_service.import_vault(VaultImportRequest(path=Path(file))) data = Path(file).read_bytes()
vault_service.import_profile(data)
typer.echo(str(file)) typer.echo(str(file))

View File

@@ -10,6 +10,7 @@ allow easy validation and documentation.
from pathlib import Path from pathlib import Path
from threading import Lock from threading import Lock
from typing import List, Optional, Dict from typing import List, Optional, Dict
import json
from pydantic import BaseModel from pydantic import BaseModel
@@ -102,6 +103,25 @@ class VaultService:
self._manager.handle_import_database(req.path) self._manager.handle_import_database(req.path)
self._manager.sync_vault() self._manager.sync_vault()
def export_profile(self) -> bytes:
"""Return encrypted profile data for backup."""
with self._lock:
data = self._manager.vault.load_index()
payload = json.dumps(data, sort_keys=True, separators=(",", ":")).encode(
"utf-8"
)
return self._manager.vault.encryption_manager.encrypt_data(payload)
def import_profile(self, data: bytes) -> None:
"""Restore a profile from ``data`` and sync."""
with self._lock:
decrypted = self._manager.vault.encryption_manager.decrypt_data(data)
index = json.loads(decrypted.decode("utf-8"))
self._manager.vault.save_index(index)
self._manager.sync_vault()
def change_password(self, req: ChangePasswordRequest) -> None: def change_password(self, req: ChangePasswordRequest) -> None:
"""Change the master password.""" """Change the master password."""

View File

@@ -84,7 +84,9 @@ def load_doc_commands() -> list[str]:
cmds = set(re.findall(r"`seedpass ([^`<>]+)`", text)) cmds = set(re.findall(r"`seedpass ([^`<>]+)`", text))
cmds = {c for c in cmds if "<" not in c and ">" not in c} cmds = {c for c in cmds if "<" not in c and ">" not in c}
cmds.discard("vault export") cmds.discard("vault export")
cmds.discard("vault export --file backup.json")
cmds.discard("vault import") cmds.discard("vault import")
cmds.discard("vault import --file backup.json")
return sorted(cmds) return sorted(cmds)

View File

@@ -0,0 +1,33 @@
from pathlib import Path
from types import SimpleNamespace
from seedpass.core.api import VaultService
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
def test_profile_export_import_round_trip(tmp_path):
dir1 = tmp_path / "a"
vault1, _ = create_vault(dir1, TEST_SEED, TEST_PASSWORD)
data = {
"schema_version": 4,
"entries": {"0": {"label": "example", "type": "password"}},
}
vault1.save_index(data)
pm1 = SimpleNamespace(vault=vault1, sync_vault=lambda: None)
service1 = VaultService(pm1)
blob = service1.export_profile()
dir2 = tmp_path / "b"
vault2, _ = create_vault(dir2, TEST_SEED, TEST_PASSWORD)
vault2.save_index({"schema_version": 4, "entries": {}})
called = {}
def sync():
called["synced"] = True
pm2 = SimpleNamespace(vault=vault2, sync_vault=sync)
service2 = VaultService(pm2)
service2.import_profile(blob)
assert called.get("synced") is True
assert vault2.load_index() == data

View File

@@ -68,58 +68,53 @@ def test_entry_get_password(monkeypatch):
def test_vault_export(monkeypatch, tmp_path): def test_vault_export(monkeypatch, tmp_path):
called = {} called = {}
def export_db(path): def export_profile(self):
called["path"] = path called["export"] = True
return b"data"
pm = SimpleNamespace( monkeypatch.setattr(cli.VaultService, "export_profile", export_profile)
handle_export_database=export_db, select_fingerprint=lambda fp: None monkeypatch.setattr(cli, "PasswordManager", lambda: SimpleNamespace())
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
out_path = tmp_path / "out.json" out_path = tmp_path / "out.json"
result = runner.invoke(app, ["vault", "export", "--file", str(out_path)]) result = runner.invoke(app, ["vault", "export", "--file", str(out_path)])
assert result.exit_code == 0 assert result.exit_code == 0
assert called["path"] == out_path assert called.get("export") is True
assert out_path.read_bytes() == b"data"
def test_vault_import(monkeypatch, tmp_path): def test_vault_import(monkeypatch, tmp_path):
called = {} called = {}
def import_db(path): def import_profile(self, data):
called["path"] = path called["data"] = data
pm = SimpleNamespace( monkeypatch.setattr(cli.VaultService, "import_profile", import_profile)
handle_import_database=import_db, monkeypatch.setattr(cli, "PasswordManager", lambda: SimpleNamespace())
select_fingerprint=lambda fp: None,
sync_vault=lambda: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
in_path = tmp_path / "in.json" in_path = tmp_path / "in.json"
in_path.write_text("{}") in_path.write_bytes(b"inp")
result = runner.invoke(app, ["vault", "import", "--file", str(in_path)]) result = runner.invoke(app, ["vault", "import", "--file", str(in_path)])
assert result.exit_code == 0 assert result.exit_code == 0
assert called["path"] == in_path assert called["data"] == b"inp"
def test_vault_import_triggers_sync(monkeypatch, tmp_path): def test_vault_import_triggers_sync(monkeypatch, tmp_path):
called = {} called = {}
def import_db(path): def import_profile(self, data):
called["path"] = path called["data"] = data
self._manager.sync_vault()
def sync(): def sync_vault():
called["sync"] = True called["sync"] = True
pm = SimpleNamespace( monkeypatch.setattr(cli.VaultService, "import_profile", import_profile)
handle_import_database=import_db, monkeypatch.setattr(
sync_vault=sync, cli, "PasswordManager", lambda: SimpleNamespace(sync_vault=sync_vault)
select_fingerprint=lambda fp: None,
) )
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
in_path = tmp_path / "in.json" in_path = tmp_path / "in.json"
in_path.write_text("{}") in_path.write_bytes(b"inp")
result = runner.invoke(app, ["vault", "import", "--file", str(in_path)]) result = runner.invoke(app, ["vault", "import", "--file", str(in_path)])
assert result.exit_code == 0 assert result.exit_code == 0
assert called["path"] == in_path assert called.get("data") == b"inp"
assert called.get("sync") is True assert called.get("sync") is True