Add vault export and relay management API

This commit is contained in:
thePR0M3TH3AN
2025-07-09 21:21:29 -04:00
parent 120cc60b41
commit 39ec8026db
3 changed files with 180 additions and 1 deletions

View File

@@ -37,7 +37,13 @@ Keep this token secret. Every request must include it in the `Authorization` hea
- `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/vault/export` Export the vault and download the encrypted file.
- `POST /api/v1/vault/backup-parent-seed` Save an encrypted backup of the parent seed.
- `POST /api/v1/vault/lock` Lock the vault and clear sensitive data from memory.
- `GET /api/v1/relays` List configured Nostr relays.
- `POST /api/v1/relays` Add a relay URL.
- `DELETE /api/v1/relays/{idx}` Remove the relay at the given index (1based).
- `POST /api/v1/relays/reset` Reset the relay list to defaults.
- `POST /api/v1/shutdown` Stop the server gracefully.
**Security Warning:** Accessing `/api/v1/parent-seed` exposes your master seed in plain text. Use it only from a trusted environment.

View File

@@ -8,7 +8,7 @@ from pathlib import Path
import secrets
from typing import Any, List, Optional
from fastapi import FastAPI, Header, HTTPException, Request
from fastapi import FastAPI, Header, HTTPException, Request, Response
import asyncio
import sys
from fastapi.middleware.cors import CORSMiddleware
@@ -28,6 +28,20 @@ def _check_token(auth: str | None) -> None:
raise HTTPException(status_code=401, detail="Unauthorized")
def _reload_relays(relays: list[str]) -> None:
"""Reload the Nostr client with a new relay list."""
assert _pm is not None
try:
_pm.nostr_client.close_client_pool()
except Exception:
pass
try:
_pm.nostr_client.relays = relays
_pm.nostr_client.initialize_client_pool()
except Exception:
pass
def start_server(fingerprint: str | None = None) -> str:
"""Initialize global state and return the API token.
@@ -383,6 +397,63 @@ def get_nostr_pubkey(authorization: str | None = Header(None)) -> Any:
return {"npub": _pm.nostr_client.key_manager.get_npub()}
@app.get("/api/v1/relays")
def list_relays(authorization: str | None = Header(None)) -> dict:
"""Return the configured Nostr relays."""
_check_token(authorization)
assert _pm is not None
cfg = _pm.config_manager.load_config(require_pin=False)
return {"relays": cfg.get("relays", [])}
@app.post("/api/v1/relays")
def add_relay(data: dict, authorization: str | None = Header(None)) -> dict[str, str]:
"""Add a relay URL to the configuration."""
_check_token(authorization)
assert _pm is not None
url = data.get("url")
if not url:
raise HTTPException(status_code=400, detail="Missing url")
cfg = _pm.config_manager.load_config(require_pin=False)
relays = cfg.get("relays", [])
if url in relays:
raise HTTPException(status_code=400, detail="Relay already present")
relays.append(url)
_pm.config_manager.set_relays(relays, require_pin=False)
_reload_relays(relays)
return {"status": "ok"}
@app.delete("/api/v1/relays/{idx}")
def remove_relay(idx: int, authorization: str | None = Header(None)) -> dict[str, str]:
"""Remove a relay by its index (1-based)."""
_check_token(authorization)
assert _pm is not None
cfg = _pm.config_manager.load_config(require_pin=False)
relays = cfg.get("relays", [])
if not (1 <= idx <= len(relays)):
raise HTTPException(status_code=400, detail="Invalid index")
if len(relays) == 1:
raise HTTPException(status_code=400, detail="At least one relay required")
relays.pop(idx - 1)
_pm.config_manager.set_relays(relays, require_pin=False)
_reload_relays(relays)
return {"status": "ok"}
@app.post("/api/v1/relays/reset")
def reset_relays(authorization: str | None = Header(None)) -> dict[str, str]:
"""Reset relay list to defaults."""
_check_token(authorization)
assert _pm is not None
from nostr.client import DEFAULT_RELAYS
relays = list(DEFAULT_RELAYS)
_pm.config_manager.set_relays(relays, require_pin=False)
_reload_relays(relays)
return {"status": "ok"}
@app.post("/api/v1/checksum/verify")
def verify_checksum(authorization: str | None = Header(None)) -> dict[str, str]:
"""Verify the SeedPass script checksum."""
@@ -401,6 +472,18 @@ def update_checksum(authorization: str | None = Header(None)) -> dict[str, str]:
return {"status": "ok"}
@app.post("/api/v1/vault/export")
def export_vault(authorization: str | None = Header(None)):
"""Export the vault and return the encrypted file."""
_check_token(authorization)
assert _pm is not None
path = _pm.handle_export_database()
if path is None:
raise HTTPException(status_code=500, detail="Export failed")
data = Path(path).read_bytes()
return Response(content=data, media_type="application/octet-stream")
@app.post("/api/v1/vault/import")
async def import_vault(
request: Request, authorization: str | None = Header(None)
@@ -432,6 +515,22 @@ async def import_vault(
return {"status": "ok"}
@app.post("/api/v1/vault/backup-parent-seed")
def backup_parent_seed(
data: dict | None = None, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Backup and reveal the parent seed."""
_check_token(authorization)
assert _pm is not None
path = None
if data is not None:
p = data.get("path")
if p:
path = Path(p)
_pm.handle_backup_reveal_parent_seed(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

@@ -300,3 +300,77 @@ def test_secret_mode_endpoint(client):
assert res.json() == {"status": "ok"}
assert called["enabled"] is True
assert called["delay"] == 12
def test_vault_export_endpoint(client, tmp_path):
cl, token = client
out = tmp_path / "out.json"
out.write_text("data")
api._pm.handle_export_database = lambda: out
headers = {"Authorization": f"Bearer {token}"}
res = cl.post("/api/v1/vault/export", headers=headers)
assert res.status_code == 200
assert res.content == b"data"
def test_backup_parent_seed_endpoint(client, tmp_path):
cl, token = client
called = {}
def backup(path=None):
called["path"] = path
api._pm.handle_backup_reveal_parent_seed = backup
path = tmp_path / "seed.enc"
headers = {"Authorization": f"Bearer {token}"}
res = cl.post(
"/api/v1/vault/backup-parent-seed",
json={"path": str(path)},
headers=headers,
)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert called["path"] == path
def test_relay_management_endpoints(client):
cl, token = client
relays = ["wss://a", "wss://b"]
def load_config(require_pin=False):
return {"relays": relays.copy()}
called = {}
def set_relays(new, require_pin=False):
called["set"] = new
api._pm.config_manager.load_config = load_config
api._pm.config_manager.set_relays = set_relays
api._pm.nostr_client = SimpleNamespace(
close_client_pool=lambda: called.setdefault("close", True),
initialize_client_pool=lambda: called.setdefault("init", True),
relays=relays,
)
headers = {"Authorization": f"Bearer {token}"}
res = cl.get("/api/v1/relays", headers=headers)
assert res.status_code == 200
assert res.json() == {"relays": relays}
res = cl.post("/api/v1/relays", json={"url": "wss://c"}, headers=headers)
assert res.status_code == 200
assert called["set"] == ["wss://a", "wss://b", "wss://c"]
api._pm.config_manager.load_config = lambda require_pin=False: {
"relays": ["wss://a", "wss://b", "wss://c"]
}
res = cl.delete("/api/v1/relays/2", headers=headers)
assert res.status_code == 200
assert called["set"] == ["wss://a", "wss://c"]
res = cl.post("/api/v1/relays/reset", headers=headers)
assert res.status_code == 200