mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 07:18:47 +00:00
Add vault export and relay management API
This commit is contained in:
@@ -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/checksum/update` – Update the stored script checksum.
|
||||||
- `POST /api/v1/change-password` – Change the master password for the active profile.
|
- `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/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.
|
- `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 (1‑based).
|
||||||
|
- `POST /api/v1/relays/reset` – Reset the relay list to defaults.
|
||||||
- `POST /api/v1/shutdown` – Stop the server gracefully.
|
- `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.
|
**Security Warning:** Accessing `/api/v1/parent-seed` exposes your master seed in plain text. Use it only from a trusted environment.
|
||||||
|
@@ -8,7 +8,7 @@ from pathlib import Path
|
|||||||
import secrets
|
import secrets
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
from fastapi import FastAPI, Header, HTTPException, Request
|
from fastapi import FastAPI, Header, HTTPException, Request, Response
|
||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -28,6 +28,20 @@ def _check_token(auth: str | None) -> None:
|
|||||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
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:
|
def start_server(fingerprint: str | None = None) -> str:
|
||||||
"""Initialize global state and return the API token.
|
"""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()}
|
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")
|
@app.post("/api/v1/checksum/verify")
|
||||||
def verify_checksum(authorization: str | None = Header(None)) -> dict[str, str]:
|
def verify_checksum(authorization: str | None = Header(None)) -> dict[str, str]:
|
||||||
"""Verify the SeedPass script checksum."""
|
"""Verify the SeedPass script checksum."""
|
||||||
@@ -401,6 +472,18 @@ def update_checksum(authorization: str | None = Header(None)) -> dict[str, str]:
|
|||||||
return {"status": "ok"}
|
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")
|
@app.post("/api/v1/vault/import")
|
||||||
async def import_vault(
|
async def import_vault(
|
||||||
request: Request, authorization: str | None = Header(None)
|
request: Request, authorization: str | None = Header(None)
|
||||||
@@ -432,6 +515,22 @@ async def import_vault(
|
|||||||
return {"status": "ok"}
|
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")
|
@app.post("/api/v1/change-password")
|
||||||
def change_password(authorization: str | None = Header(None)) -> dict[str, str]:
|
def change_password(authorization: str | None = Header(None)) -> dict[str, str]:
|
||||||
"""Change the master password for the active profile."""
|
"""Change the master password for the active profile."""
|
||||||
|
@@ -300,3 +300,77 @@ def test_secret_mode_endpoint(client):
|
|||||||
assert res.json() == {"status": "ok"}
|
assert res.json() == {"status": "ok"}
|
||||||
assert called["enabled"] is True
|
assert called["enabled"] is True
|
||||||
assert called["delay"] == 12
|
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
|
||||||
|
Reference in New Issue
Block a user