diff --git a/docs/api_reference.md b/docs/api_reference.md index ec6619d..1be8d34 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -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 (1‑based). +- `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. diff --git a/src/seedpass/api.py b/src/seedpass/api.py index 6827934..c1482e1 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -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.""" diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index fc187db..f8ac6f0 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -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