mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 15:28:44 +00:00
Merge pull request #642 from PR0M3TH3AN/codex/implement-export-and-import-profile-commands
Add vault profile export/import feature
This commit is contained in:
@@ -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
|
||||||
|
@@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
@@ -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."""
|
||||||
|
|
||||||
|
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
33
src/tests/test_profile_export_import.py
Normal file
33
src/tests/test_profile_export_import.py
Normal 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
|
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user