diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index a4d072a..35b069d 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -207,10 +207,10 @@ create a backup: seedpass # Export your index -seedpass export --file "~/seedpass_backup.json" +seedpass vault export --file "~/seedpass_backup.json" # 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 # Quickly find or retrieve entries diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 4b9a917..df7f56c 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -14,8 +14,6 @@ from seedpass.core.api import ( ConfigService, UtilityService, NostrService, - VaultExportRequest, - VaultImportRequest, ChangePasswordRequest, UnlockRequest, BackupParentSeedRequest, @@ -402,9 +400,10 @@ def entry_export_totp( def vault_export( ctx: typer.Context, file: str = typer.Option(..., help="Output file") ) -> None: - """Export the vault.""" + """Export the vault profile to an encrypted file.""" 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)) @@ -412,9 +411,10 @@ def vault_export( def vault_import( ctx: typer.Context, file: str = typer.Option(..., help="Input file") ) -> 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.import_vault(VaultImportRequest(path=Path(file))) + data = Path(file).read_bytes() + vault_service.import_profile(data) typer.echo(str(file)) diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py index 6acb958..7078853 100644 --- a/src/seedpass/core/api.py +++ b/src/seedpass/core/api.py @@ -10,6 +10,7 @@ allow easy validation and documentation. from pathlib import Path from threading import Lock from typing import List, Optional, Dict +import json from pydantic import BaseModel @@ -102,6 +103,25 @@ class VaultService: self._manager.handle_import_database(req.path) 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: """Change the master password.""" diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py index 7f4ce82..1518261 100644 --- a/src/tests/test_cli_doc_examples.py +++ b/src/tests/test_cli_doc_examples.py @@ -84,7 +84,9 @@ def load_doc_commands() -> list[str]: cmds = set(re.findall(r"`seedpass ([^`<>]+)`", text)) cmds = {c for c in cmds if "<" not in c and ">" not in c} cmds.discard("vault export") + cmds.discard("vault export --file backup.json") cmds.discard("vault import") + cmds.discard("vault import --file backup.json") return sorted(cmds) diff --git a/src/tests/test_profile_export_import.py b/src/tests/test_profile_export_import.py new file mode 100644 index 0000000..0929d95 --- /dev/null +++ b/src/tests/test_profile_export_import.py @@ -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 diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index 3d51993..c2db4da 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -68,58 +68,53 @@ def test_entry_get_password(monkeypatch): def test_vault_export(monkeypatch, tmp_path): called = {} - def export_db(path): - called["path"] = path + def export_profile(self): + called["export"] = True + return b"data" - pm = SimpleNamespace( - handle_export_database=export_db, select_fingerprint=lambda fp: None - ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli.VaultService, "export_profile", export_profile) + monkeypatch.setattr(cli, "PasswordManager", lambda: SimpleNamespace()) out_path = tmp_path / "out.json" result = runner.invoke(app, ["vault", "export", "--file", str(out_path)]) 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): called = {} - def import_db(path): - called["path"] = path + def import_profile(self, data): + called["data"] = data - pm = SimpleNamespace( - handle_import_database=import_db, - select_fingerprint=lambda fp: None, - sync_vault=lambda: None, - ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli.VaultService, "import_profile", import_profile) + monkeypatch.setattr(cli, "PasswordManager", lambda: SimpleNamespace()) 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)]) assert result.exit_code == 0 - assert called["path"] == in_path + assert called["data"] == b"inp" def test_vault_import_triggers_sync(monkeypatch, tmp_path): called = {} - def import_db(path): - called["path"] = path + def import_profile(self, data): + called["data"] = data + self._manager.sync_vault() - def sync(): + def sync_vault(): called["sync"] = True - pm = SimpleNamespace( - handle_import_database=import_db, - sync_vault=sync, - select_fingerprint=lambda fp: None, + monkeypatch.setattr(cli.VaultService, "import_profile", import_profile) + monkeypatch.setattr( + cli, "PasswordManager", lambda: SimpleNamespace(sync_vault=sync_vault) ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) 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)]) assert result.exit_code == 0 - assert called["path"] == in_path + assert called.get("data") == b"inp" assert called.get("sync") is True