Add vault import support

This commit is contained in:
thePR0M3TH3AN
2025-07-09 16:45:32 -04:00
parent 8ebbeab65c
commit 068611a02c
7 changed files with 111 additions and 1 deletions

View File

@@ -68,6 +68,7 @@ Manage the entire vault for a profile.
| Action | Command | Examples |
| :--- | :--- | :--- |
| Export the vault | `vault export` | `seedpass vault export --file backup.json` |
| Import a vault | `vault import` | `seedpass vault import --file backup.json` |
| Change the master password | `vault change-password` | `seedpass vault change-password` |
### Nostr Commands
@@ -150,6 +151,7 @@ Code: 123456
### `vault` Commands
- **`seedpass vault export`** Export the entire vault to an encrypted JSON file.
- **`seedpass vault import`** Import a vault from an encrypted JSON file.
- **`seedpass vault change-password`** Change the master password used for encryption.
### `nostr` Commands

View File

@@ -31,6 +31,7 @@ Keep this token secret. Every request must include it in the `Authorization` hea
- `POST /api/v1/checksum/verify` Verify the checksum of the running script.
- `POST /api/v1/checksum/update` Update the stored script checksum.
- `POST /api/v1/change-password` Change the master password for the active profile.
- `POST /api/v1/vault/import` Import a vault backup from a file or path.
- `POST /api/v1/shutdown` Stop the server gracefully.
## Example Requests

View File

@@ -29,3 +29,4 @@ fastapi>=0.116.0
uvicorn>=0.35.0
httpx>=0.28.1
requests>=2.32
python-multipart

View File

@@ -3,10 +3,12 @@
from __future__ import annotations
import os
import tempfile
from pathlib import Path
import secrets
from typing import Any, List, Optional
from fastapi import FastAPI, Header, HTTPException
from fastapi import FastAPI, Header, HTTPException, Request
import asyncio
import sys
from fastapi.middleware.cors import CORSMiddleware
@@ -329,6 +331,37 @@ def update_checksum(authorization: str | None = Header(None)) -> dict[str, str]:
return {"status": "ok"}
@app.post("/api/v1/vault/import")
async def import_vault(
request: Request, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Import a vault backup from a file upload or a server path."""
_check_token(authorization)
assert _pm is not None
ctype = request.headers.get("content-type", "")
if ctype.startswith("multipart/form-data"):
form = await request.form()
file = form.get("file")
if file is None:
raise HTTPException(status_code=400, detail="Missing file")
data = await file.read()
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp.write(data)
tmp_path = Path(tmp.name)
try:
_pm.handle_import_database(tmp_path)
finally:
os.unlink(tmp_path)
else:
body = await request.json()
path = body.get("path")
if not path:
raise HTTPException(status_code=400, detail="Missing file or path")
_pm.handle_import_database(Path(path))
return {"status": "ok"}
@app.post("/api/v1/change-password")
def change_password(authorization: str | None = Header(None)) -> dict[str, str]:
"""Change the master password for the active profile."""

View File

@@ -324,6 +324,16 @@ def vault_export(
typer.echo(str(file))
@vault_app.command("import")
def vault_import(
ctx: typer.Context, file: str = typer.Option(..., help="Input file")
) -> None:
"""Import a vault from an encrypted JSON file."""
pm = _get_pm(ctx)
pm.handle_import_database(Path(file))
typer.echo(str(file))
@vault_app.command("change-password")
def vault_change_password(ctx: typer.Context) -> None:
"""Change the master password used for encryption."""

View File

@@ -1,4 +1,5 @@
from types import SimpleNamespace
from pathlib import Path
import pytest
from seedpass import api
@@ -161,3 +162,48 @@ def test_checksum_endpoints(client):
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert calls.get("update") is True
def test_vault_import_via_path(client, tmp_path):
cl, token = client
called = {}
def import_db(path):
called["path"] = path
api._pm.handle_import_database = import_db
file_path = tmp_path / "b.json"
file_path.write_text("{}")
headers = {"Authorization": f"Bearer {token}"}
res = cl.post(
"/api/v1/vault/import",
json={"path": str(file_path)},
headers=headers,
)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert called["path"] == file_path
def test_vault_import_via_upload(client, tmp_path):
cl, token = client
called = {}
def import_db(path):
called["path"] = path
api._pm.handle_import_database = import_db
file_path = tmp_path / "c.json"
file_path.write_text("{}")
headers = {"Authorization": f"Bearer {token}"}
with open(file_path, "rb") as fh:
res = cl.post(
"/api/v1/vault/import",
files={"file": ("c.json", fh.read())},
headers=headers,
)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert isinstance(called.get("path"), Path)

View File

@@ -81,6 +81,23 @@ def test_vault_export(monkeypatch, tmp_path):
assert called["path"] == out_path
def test_vault_import(monkeypatch, tmp_path):
called = {}
def import_db(path):
called["path"] = path
pm = SimpleNamespace(
handle_import_database=import_db, select_fingerprint=lambda fp: None
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
in_path = tmp_path / "in.json"
in_path.write_text("{}")
result = runner.invoke(app, ["vault", "import", "--file", str(in_path)])
assert result.exit_code == 0
assert called["path"] == in_path
def test_vault_change_password(monkeypatch):
called = {}