feat: add short-lived JWT auth and secure endpoints

This commit is contained in:
thePR0M3TH3AN
2025-08-02 21:48:52 -04:00
parent 8c9fe07609
commit 186e39cc91
6 changed files with 104 additions and 16 deletions

View File

@@ -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. - **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. - **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 TLSterminating 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 ## Contributing
Contributions are welcome! If you have suggestions for improvements, bug fixes, or new features, please follow these steps: Contributions are welcome! If you have suggestions for improvements, bug fixes, or new features, please follow these steps:

View File

@@ -7,14 +7,14 @@ This guide covers how to start the SeedPass API, authenticate requests, and inte
## Starting the API ## Starting the API
Run `seedpass api start` from your terminal. The command prints a onetime token used for authentication: Run `seedpass api start` from your terminal. The command prints a shortlived JWT token used for authentication:
```bash ```bash
$ seedpass api start $ 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 ## 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/totp` Return current TOTP codes and remaining time.
- `GET /api/v1/stats` Return statistics about the active seed profile. - `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/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. - `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/verify` Verify the checksum of the running script.
- `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/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/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. - `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. **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 ## Example Requests
Send requests with the token in the header: Send requests with the token in the header:
```bash ```bash
curl -H "Authorization: Bearer <token>" \ curl -H "Authorization: Bearer <token>" \
"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 ### 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`: Download an encrypted vault backup via `POST /api/v1/vault/export`:
```bash ```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 <token>" \ -H "Authorization: Bearer <token>" \
-H "X-SeedPass-Password: <master-password>" \
-o backup.json -o backup.json
``` ```

View File

@@ -31,6 +31,7 @@ starlette>=0.47.2
httpx>=0.28.1 httpx>=0.28.1
requests>=2.32 requests>=2.32
python-multipart python-multipart
PyJWT
orjson orjson
argon2-cffi argon2-cffi
toga-core>=0.5.2 toga-core>=0.5.2

View File

@@ -9,6 +9,9 @@ import secrets
import queue import queue
from typing import Any, List, Optional from typing import Any, List, Optional
from datetime import datetime, timedelta, timezone
import jwt
from fastapi import FastAPI, Header, HTTPException, Request, Response from fastapi import FastAPI, Header, HTTPException, Request, Response
import asyncio import asyncio
import sys import sys
@@ -23,10 +26,18 @@ app = FastAPI()
_pm: Optional[PasswordManager] = None _pm: Optional[PasswordManager] = None
_token: str = "" _token: str = ""
_jwt_secret: str = ""
def _check_token(auth: str | None) -> None: 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") 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: 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 Parameters
---------- ----------
fingerprint: fingerprint:
Optional seed profile fingerprint to select before starting the server. Optional seed profile fingerprint to select before starting the server.
""" """
global _pm, _token global _pm, _token, _jwt_secret
if fingerprint is None: if fingerprint is None:
_pm = PasswordManager() _pm = PasswordManager()
else: else:
_pm = PasswordManager(fingerprint=fingerprint) _pm = PasswordManager(fingerprint=fingerprint)
_token = secrets.token_urlsafe(16) _jwt_secret = secrets.token_urlsafe(32)
print(f"API token: {_token}") payload = {"exp": datetime.now(timezone.utc) + timedelta(minutes=5)}
_token = jwt.encode(payload, _jwt_secret, algorithm="HS256")
origins = [ origins = [
o.strip() o.strip()
for o in os.getenv("SEEDPASS_CORS_ORIGINS", "").split(",") for o in os.getenv("SEEDPASS_CORS_ORIGINS", "").split(",")
@@ -74,6 +86,12 @@ def start_server(fingerprint: str | None = None) -> str:
return _token 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") @app.get("/api/v1/entry")
def search_entry(query: str, authorization: str | None = Header(None)) -> List[Any]: def search_entry(query: str, authorization: str | None = Header(None)) -> List[Any]:
_check_token(authorization) _check_token(authorization)
@@ -414,10 +432,13 @@ def get_notifications(authorization: str | None = Header(None)) -> List[dict]:
@app.get("/api/v1/parent-seed") @app.get("/api/v1/parent-seed")
def get_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: ) -> dict:
"""Return the parent seed or save it as an encrypted backup.""" """Return the parent seed or save it as an encrypted backup."""
_check_token(authorization) _check_token(authorization)
_require_password(password)
assert _pm is not None assert _pm is not None
if file: if file:
path = Path(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") @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.""" """Export the vault and return the encrypted file."""
_check_token(authorization) _check_token(authorization)
_require_password(password)
assert _pm is not None assert _pm is not None
path = _pm.handle_export_database() path = _pm.handle_export_database()
if path is None: if path is None:

View File

@@ -39,6 +39,7 @@ def client(monkeypatch):
nostr_client=SimpleNamespace( nostr_client=SimpleNamespace(
key_manager=SimpleNamespace(get_npub=lambda: "np") key_manager=SimpleNamespace(get_npub=lambda: "np")
), ),
verify_password=lambda pw: True,
) )
monkeypatch.setattr(api, "PasswordManager", lambda: dummy) monkeypatch.setattr(api, "PasswordManager", lambda: dummy)
monkeypatch.setenv("SEEDPASS_CORS_ORIGINS", "http://example.com") monkeypatch.setenv("SEEDPASS_CORS_ORIGINS", "http://example.com")

View File

@@ -162,7 +162,10 @@ def test_parent_seed_endpoint(client, tmp_path):
api._pm.encryption_manager = SimpleNamespace( api._pm.encryption_manager = SimpleNamespace(
encrypt_and_save_file=lambda data, path: called.setdefault("path", path) 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) res = cl.get("/api/v1/parent-seed", headers=headers)
assert res.status_code == 200 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 res.json() == {"status": "saved", "path": str(out)}
assert called["path"] == 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): def test_fingerprint_endpoints(client):
cl, token = client cl, token = client
@@ -330,11 +336,17 @@ def test_vault_export_endpoint(client, tmp_path):
api._pm.handle_export_database = lambda: out 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) res = cl.post("/api/v1/vault/export", headers=headers)
assert res.status_code == 200 assert res.status_code == 200
assert res.content == b"data" 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): def test_backup_parent_seed_endpoint(client, tmp_path):
cl, token = client cl, token = client