From 186e39cc91413610b9f1fc2d356bacebc045c471 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 2 Aug 2025 21:48:52 -0400 Subject: [PATCH] feat: add short-lived JWT auth and secure endpoints --- README.md | 24 ++++++++++++ .../01-getting-started/02-api_reference.md | 39 +++++++++++++++---- src/requirements.txt | 1 + src/seedpass/api.py | 39 +++++++++++++++---- src/tests/test_api.py | 1 + src/tests/test_api_new_endpoints.py | 16 +++++++- 6 files changed, 104 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index f99e4b3..122fd12 100644 --- a/README.md +++ b/README.md @@ -717,6 +717,30 @@ You can also launch the GUI directly with `seedpass gui` or `seedpass-gui`. - **Offline Mode:** When enabled, SeedPass skips all Nostr operations so your vault stays local until syncing is turned back on. - **Quick Unlock:** Stores a hashed copy of your password in the encrypted config so you only need to enter it once per session. Avoid this on shared computers. +### Secure Deployment + +Always deploy SeedPass behind HTTPS. Place a TLS‑terminating reverse proxy such as Nginx in front of the FastAPI server or configure Uvicorn with certificate files. Example Nginx snippet: + +``` +server { + listen 443 ssl; + ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +For local testing, Uvicorn can run with TLS directly: + +``` +uvicorn seedpass.api:app --ssl-certfile=cert.pem --ssl-keyfile=key.pem +``` + ## Contributing Contributions are welcome! If you have suggestions for improvements, bug fixes, or new features, please follow these steps: diff --git a/docs/docs/content/01-getting-started/02-api_reference.md b/docs/docs/content/01-getting-started/02-api_reference.md index 38ac3b6..00bfa64 100644 --- a/docs/docs/content/01-getting-started/02-api_reference.md +++ b/docs/docs/content/01-getting-started/02-api_reference.md @@ -7,14 +7,14 @@ This guide covers how to start the SeedPass API, authenticate requests, and inte ## Starting the API -Run `seedpass api start` from your terminal. The command prints a one‑time token used for authentication: +Run `seedpass api start` from your terminal. The command prints a short‑lived JWT token used for authentication: ```bash $ seedpass api start -API token: abcdef1234567890 +API token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9... ``` -Keep this token secret. Every request must include it in the `Authorization` header using the `Bearer` scheme. +Keep this token secret and avoid logging it. Tokens expire after a few minutes and every request must include one in the `Authorization` header using the `Bearer` scheme. ## Endpoints @@ -35,13 +35,13 @@ Keep this token secret. Every request must include it in the `Authorization` hea - `GET /api/v1/totp` – Return current TOTP codes and remaining time. - `GET /api/v1/stats` – Return statistics about the active seed profile. - `GET /api/v1/notifications` – Retrieve and clear queued notifications. Messages appear in the persistent notification box but remain queued until fetched. -- `GET /api/v1/parent-seed` – Reveal the parent seed or save it with `?file=`. +- `GET /api/v1/parent-seed` – Reveal the parent seed or save it with `?file=`. Requires an additional `X-SeedPass-Password` header. - `GET /api/v1/nostr/pubkey` – Fetch the Nostr public key for the active seed. - `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/vault/export` – Export the vault and download the encrypted file. +- `POST /api/v1/vault/export` – Export the vault and download the encrypted file. Requires an additional `X-SeedPass-Password` header. - `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. @@ -52,13 +52,37 @@ Keep this token secret. Every request must include it in the `Authorization` hea **Security Warning:** Accessing `/api/v1/parent-seed` exposes your master seed in plain text. Use it only from a trusted environment. +## Secure Deployment + +Always run the API behind HTTPS. Use a reverse proxy such as Nginx or Caddy to terminate TLS and forward requests to SeedPass. Example Nginx configuration: + +``` +server { + listen 443 ssl; + ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +For local testing, Uvicorn can serve TLS directly: + +``` +uvicorn seedpass.api:app --ssl-certfile=cert.pem --ssl-keyfile=key.pem +``` + ## Example Requests Send requests with the token in the header: ```bash curl -H "Authorization: Bearer " \ - "http://127.0.0.1:8000/api/v1/entry?query=email" + "https://127.0.0.1:8000/api/v1/entry?query=email" ``` ### Creating an Entry @@ -149,8 +173,9 @@ curl -X POST http://127.0.0.1:8000/api/v1/fingerprint/select \ Download an encrypted vault backup via `POST /api/v1/vault/export`: ```bash -curl -X POST http://127.0.0.1:8000/api/v1/vault/export \ +curl -X POST https://127.0.0.1:8000/api/v1/vault/export \ -H "Authorization: Bearer " \ + -H "X-SeedPass-Password: " \ -o backup.json ``` diff --git a/src/requirements.txt b/src/requirements.txt index b4f5c96..d16ac24 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -31,6 +31,7 @@ starlette>=0.47.2 httpx>=0.28.1 requests>=2.32 python-multipart +PyJWT orjson argon2-cffi toga-core>=0.5.2 diff --git a/src/seedpass/api.py b/src/seedpass/api.py index 8b22903..c77a862 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -9,6 +9,9 @@ import secrets import queue from typing import Any, List, Optional +from datetime import datetime, timedelta, timezone +import jwt + from fastapi import FastAPI, Header, HTTPException, Request, Response import asyncio import sys @@ -23,10 +26,18 @@ app = FastAPI() _pm: Optional[PasswordManager] = None _token: str = "" +_jwt_secret: str = "" def _check_token(auth: str | None) -> None: - if auth != f"Bearer {_token}": + if auth is None or not auth.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Unauthorized") + token = auth.split(" ", 1)[1] + try: + jwt.decode(token, _jwt_secret, algorithms=["HS256"]) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token expired") + except jwt.InvalidTokenError: raise HTTPException(status_code=401, detail="Unauthorized") @@ -45,20 +56,21 @@ def _reload_relays(relays: list[str]) -> None: def start_server(fingerprint: str | None = None) -> str: - """Initialize global state and return the API token. + """Initialize global state and return a short-lived JWT token. Parameters ---------- fingerprint: Optional seed profile fingerprint to select before starting the server. """ - global _pm, _token + global _pm, _token, _jwt_secret if fingerprint is None: _pm = PasswordManager() else: _pm = PasswordManager(fingerprint=fingerprint) - _token = secrets.token_urlsafe(16) - print(f"API token: {_token}") + _jwt_secret = secrets.token_urlsafe(32) + payload = {"exp": datetime.now(timezone.utc) + timedelta(minutes=5)} + _token = jwt.encode(payload, _jwt_secret, algorithm="HS256") origins = [ o.strip() for o in os.getenv("SEEDPASS_CORS_ORIGINS", "").split(",") @@ -74,6 +86,12 @@ def start_server(fingerprint: str | None = None) -> str: return _token +def _require_password(password: str | None) -> None: + assert _pm is not None + if password is None or not _pm.verify_password(password): + raise HTTPException(status_code=401, detail="Invalid password") + + @app.get("/api/v1/entry") def search_entry(query: str, authorization: str | None = Header(None)) -> List[Any]: _check_token(authorization) @@ -414,10 +432,13 @@ def get_notifications(authorization: str | None = Header(None)) -> List[dict]: @app.get("/api/v1/parent-seed") def get_parent_seed( - authorization: str | None = Header(None), file: str | None = None + authorization: str | None = Header(None), + file: str | None = None, + password: str | None = Header(None, alias="X-SeedPass-Password"), ) -> dict: """Return the parent seed or save it as an encrypted backup.""" _check_token(authorization) + _require_password(password) assert _pm is not None if file: path = Path(file) @@ -511,9 +532,13 @@ def update_checksum(authorization: str | None = Header(None)) -> dict[str, str]: @app.post("/api/v1/vault/export") -def export_vault(authorization: str | None = Header(None)): +def export_vault( + authorization: str | None = Header(None), + password: str | None = Header(None, alias="X-SeedPass-Password"), +): """Export the vault and return the encrypted file.""" _check_token(authorization) + _require_password(password) assert _pm is not None path = _pm.handle_export_database() if path is None: diff --git a/src/tests/test_api.py b/src/tests/test_api.py index 6d0db7f..118e3a0 100644 --- a/src/tests/test_api.py +++ b/src/tests/test_api.py @@ -39,6 +39,7 @@ def client(monkeypatch): nostr_client=SimpleNamespace( key_manager=SimpleNamespace(get_npub=lambda: "np") ), + verify_password=lambda pw: True, ) monkeypatch.setattr(api, "PasswordManager", lambda: dummy) monkeypatch.setenv("SEEDPASS_CORS_ORIGINS", "http://example.com") diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index 853a644..581ada4 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -162,7 +162,10 @@ def test_parent_seed_endpoint(client, tmp_path): api._pm.encryption_manager = SimpleNamespace( encrypt_and_save_file=lambda data, path: called.setdefault("path", path) ) - headers = {"Authorization": f"Bearer {token}"} + headers = { + "Authorization": f"Bearer {token}", + "X-SeedPass-Password": "pw", + } res = cl.get("/api/v1/parent-seed", headers=headers) assert res.status_code == 200 @@ -174,6 +177,9 @@ def test_parent_seed_endpoint(client, tmp_path): assert res.json() == {"status": "saved", "path": str(out)} assert called["path"] == out + res = cl.get("/api/v1/parent-seed", headers={"Authorization": f"Bearer {token}"}) + assert res.status_code == 401 + def test_fingerprint_endpoints(client): cl, token = client @@ -330,11 +336,17 @@ def test_vault_export_endpoint(client, tmp_path): api._pm.handle_export_database = lambda: out - headers = {"Authorization": f"Bearer {token}"} + headers = { + "Authorization": f"Bearer {token}", + "X-SeedPass-Password": "pw", + } res = cl.post("/api/v1/vault/export", headers=headers) assert res.status_code == 200 assert res.content == b"data" + res = cl.post("/api/v1/vault/export", headers={"Authorization": f"Bearer {token}"}) + assert res.status_code == 401 + def test_backup_parent_seed_endpoint(client, tmp_path): cl, token = client